Compare commits
49 Commits
0b2d635fd8
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
b45139f429
|
|||
|
60139f48f2
|
|||
|
ca85dc2265
|
|||
|
1dbf3b36ff
|
|||
|
2982d44e55
|
|||
|
cb6422aacb
|
|||
|
a73c0302a4
|
|||
|
4cefc4e0ad
|
|||
|
5b619670ee
|
|||
|
9c731d6e67
|
|||
| 26536a3cde | |||
|
42ce022d7b
|
|||
|
6ead4b8541
|
|||
|
0b81d3d803
|
|||
|
3d8d74785c
|
|||
|
1c58de68b6
|
|||
|
c28e4874d9
|
|||
|
680e8dafff
|
|||
|
b170fcfa98
|
|||
|
4773338ccc
|
|||
|
d9b7c88ccf
|
|||
|
7b77387182
|
|||
|
65951a23ce
|
|||
|
5bbc100d83
|
|||
|
cf489c9f4a
|
|||
|
eeda177182
|
|||
|
0681bd398c
|
|||
|
ea4738ad06
|
|||
|
51f02ff5c8
|
|||
|
b311bcae11
|
|||
|
063ff3ca58
|
|||
|
fc3a0219a0
|
|||
|
a6befb5aeb
|
|||
|
cc79e43b3c
|
|||
|
367e9fbdb6
|
|||
|
6137121209
|
|||
|
e8e37e8967
|
|||
|
3cd30d3bad
|
|||
|
bea2d98fa6
|
|||
|
d82ed3d43a
|
|||
|
e541000347
|
|||
|
b7ec488b44
|
|||
|
9b69069367
|
|||
|
6930bb3a61
|
|||
|
715af77a8c
|
|||
|
6280299770
|
|||
|
a39862b5ab
|
|||
|
ca7fbe9b80
|
|||
|
729e7f5fca
|
@@ -9,18 +9,22 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Rewrite SSH submodule URLs to HTTPS for CI
|
||||
run: |
|
||||
git config --global url."https://x-token:${{ secrets.ACTION_ACCESS_TOKEN }}@git.yiprawr.dev/".insteadOf "git@git.yiprawr.dev:"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: '${{ secrets.ACTION_ACCESS_TOKEN }}'
|
||||
submodules: recursive
|
||||
lfs: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ vars.DOCKER_REGISTRY_URL }}
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
@@ -30,7 +34,7 @@ jobs:
|
||||
run: echo "REPO_OWNER_LC=$(echo '${{ gitea.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push Docker image for latest tag
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
+6
-3
@@ -1,8 +1,11 @@
|
||||
__pycache__/
|
||||
uploads/
|
||||
.venv/
|
||||
__pycache__/
|
||||
|
||||
*.pyc
|
||||
*.json
|
||||
*.edgeql
|
||||
|
||||
.env
|
||||
|
||||
access.log
|
||||
valkey_data/
|
||||
redisinsight/
|
||||
|
||||
@@ -4,3 +4,9 @@
|
||||
[submodule "my_helpers"]
|
||||
path = my_helpers
|
||||
url = git@git.yiprawr.dev:daniel156161/python-helper-modules.git
|
||||
[submodule "quart_common"]
|
||||
path = quart_common
|
||||
url = git@git.yiprawr.dev:submodules/python-quart-common.git
|
||||
[submodule "servicelink"]
|
||||
path = servicelink
|
||||
url = git@git.yiprawr.dev:submodules/servicelink.git
|
||||
|
||||
+1
-1
@@ -10,4 +10,4 @@ RUN uv sync --no-config --frozen --compile-bytecode
|
||||
# Starten Sie Ihre Anwendung
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uv", "run", "hypercorn", "run:app", "--bind", "0.0.0.0:8000", "--workers", "1", "--websocket-ping-interval", "20", "--access-logfile", "-"]
|
||||
CMD [".venv/bin/hypercorn", "run:app", "--bind", "0.0.0.0:8000", "--workers", "1", "--websocket-ping-interval", "20", "--access-logfile", "-"]
|
||||
|
||||
+1
-1
Submodule my_helpers updated: c47b25903f...059338cffb
@@ -0,0 +1,67 @@
|
||||
from redis.asyncio import Redis as aioredis
|
||||
from collections import defaultdict
|
||||
import asyncio, time
|
||||
|
||||
class OrphanStorageIdRegistry:
|
||||
def __init__(self, retention_seconds:int=600, redis_client:aioredis=None):
|
||||
self.retention_seconds = max(60, int(retention_seconds))
|
||||
self.redis = redis_client
|
||||
self._lock = asyncio.Lock()
|
||||
self._store: dict[tuple[str, str], list[tuple[str, float]]] = defaultdict(list)
|
||||
self._prefix = "upload:orphan:"
|
||||
|
||||
def _key(self, user_id:str, fingerprint:str) -> str:
|
||||
return f"{self._prefix}{user_id}:{fingerprint}"
|
||||
|
||||
def _prune_locked(self, now:float):
|
||||
threshold = now - self.retention_seconds
|
||||
for key in list(self._store.keys()):
|
||||
entries = [entry for entry in self._store[key] if entry[1] >= threshold]
|
||||
if entries:
|
||||
self._store[key] = entries
|
||||
else:
|
||||
self._store.pop(key, None)
|
||||
|
||||
async def remember(self, user_id:str, fingerprint:str|None, storage_id:str):
|
||||
if not fingerprint or not storage_id:
|
||||
return
|
||||
|
||||
if self.redis is not None:
|
||||
key = self._key(user_id, fingerprint)
|
||||
pipe = self.redis.pipeline()
|
||||
pipe.lpush(key, storage_id)
|
||||
pipe.expire(key, self.retention_seconds)
|
||||
await pipe.execute()
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
now = time.time()
|
||||
self._prune_locked(now)
|
||||
self._store[(user_id, fingerprint)].append((storage_id, now))
|
||||
|
||||
async def pop_recent(self, user_id:str, fingerprint:str|None) -> str|None:
|
||||
if not fingerprint:
|
||||
return None
|
||||
|
||||
if self.redis is not None:
|
||||
key = self._key(user_id, fingerprint)
|
||||
value = await self.redis.rpop(key)
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="ignore")
|
||||
return str(value)
|
||||
|
||||
async with self._lock:
|
||||
self._prune_locked(time.time())
|
||||
entries = self._store.get((user_id, fingerprint))
|
||||
if not entries:
|
||||
return None
|
||||
storage_id, _ = entries.pop()
|
||||
if not entries:
|
||||
self._store.pop((user_id, fingerprint), None)
|
||||
return storage_id
|
||||
|
||||
async def close(self):
|
||||
if self.redis is not None:
|
||||
await self.redis.aclose()
|
||||
@@ -0,0 +1,14 @@
|
||||
class TheIPManager:
|
||||
def __init__(self):
|
||||
self.always_allowed_ips = set()
|
||||
|
||||
# Checks
|
||||
def is_client_ip_always_allowed(self, client_ip:str):
|
||||
return client_ip in self.always_allowed_ips
|
||||
|
||||
# Add
|
||||
def add_always_allowed_ip(self, client_ip:str):
|
||||
self.always_allowed_ips.add(client_ip)
|
||||
|
||||
def update_always_allowed_ip(self, client_ips:set):
|
||||
self.always_allowed_ips.update(client_ips)
|
||||
@@ -1,7 +1,7 @@
|
||||
from my_modules.TheIPManager import TheIPManager
|
||||
from my_modules.app.logger import logger
|
||||
|
||||
from dotenv import find_dotenv, load_dotenv, dotenv_values
|
||||
from pathlib import Path
|
||||
import os, asyncio
|
||||
|
||||
async def read_dot_file():
|
||||
@@ -17,5 +17,11 @@ WEB_DEBUG = os.getenv("WEB_DEBUG", False)
|
||||
SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "USE_ENV_das_ist_ein_geheimer_schlüssel_1")
|
||||
API_GROUP = os.getenv("API_GROUP", 'NanoShare')
|
||||
|
||||
UPLOAD_DIR = Path("uploads")
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
THE_IP_BOT_MANAGER = TheIPManager()
|
||||
|
||||
SKIP_PATH_PREFIXES = ("/static", "/storage")
|
||||
SKIP_PATHS = ("/favicon.ico",)
|
||||
|
||||
# Blocke IPs (Bots, Hackers)
|
||||
BLOCKED_IPS_ACCESSING_TIMES = int(os.getenv("BLOCKE_IPS_AFTER_ACCESSING_HOWMANY_TIME", 5))
|
||||
BLOCKED_IPS_STORED_TIMEFRAME = int(os.getenv("BLOCKE_IPS_STORE_KEYS_TIMEFRAME", 3600))
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
from aiologger.formatters.base import Formatter
|
||||
from aiologger.handlers.streams import AsyncStreamHandler
|
||||
from aiologger import Logger
|
||||
import os
|
||||
import sys
|
||||
from quart_common.web.logger import build_logger
|
||||
|
||||
formatter = Formatter(fmt="%(levelname)s %(module)s: %(message)s")
|
||||
handler = AsyncStreamHandler(stream=sys.stdout)
|
||||
handler.formatter = formatter
|
||||
|
||||
logger = Logger(name="my_webside_and_api", level="DEBUG" if os.getenv("WEB_DEBUG", False) == "true" else "INFO")
|
||||
logger.handlers = [handler]
|
||||
logger = build_logger(name="nanoshare")
|
||||
|
||||
+67
-19
@@ -1,8 +1,18 @@
|
||||
from my_modules.functions import custom_limit_key
|
||||
from my_modules.app.constens import SECRET_KEY, UPLOAD_DIR
|
||||
from my_modules.functions import (
|
||||
custom_limit_key,
|
||||
get_my_ip_address,
|
||||
get_local_ip_addresses,
|
||||
replace_last_ip_segment,
|
||||
generate_all_ips,
|
||||
)
|
||||
from my_modules.app.constens import SECRET_KEY, THE_IP_BOT_MANAGER
|
||||
from my_modules.OrphanStorageIdRegistry import OrphanStorageIdRegistry
|
||||
from my_modules.AsyncCache import AsyncCache
|
||||
from my_modules.app.logger import logger
|
||||
from quart_common.web.wide_event import register_wide_event_logging
|
||||
|
||||
from my_helpers.db.convex.ConvexRuntime import ConvexRuntime
|
||||
from my_helpers.db.convex.ConvexWorkerPool import ConvexWorkerPool
|
||||
from my_modules.db.ConvexDB import ConvexDB
|
||||
|
||||
from quart_session import Session
|
||||
@@ -12,11 +22,14 @@ import redis.asyncio as aioredis
|
||||
from quart import Quart
|
||||
import os
|
||||
|
||||
app = Quart(__name__, template_folder="../../templates/side", static_folder="../../templates/static")
|
||||
app = Quart(__name__,
|
||||
template_folder="../../templates/side",
|
||||
static_folder="../../templates/static",
|
||||
)
|
||||
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 * 1024
|
||||
register_wide_event_logging(app, logger)
|
||||
|
||||
app.secret_key = SECRET_KEY
|
||||
app.upload_folder = UPLOAD_DIR
|
||||
|
||||
# Cache, Sessions and Limiter over Valkey
|
||||
if os.getenv("VALKEY_HOST", None) is not None:
|
||||
@@ -27,7 +40,7 @@ if os.getenv("VALKEY_HOST", None) is not None:
|
||||
password=os.getenv('VALKEY_CACHE_PASSWORD', ''),
|
||||
host=os.getenv('VALKEY_HOST'),
|
||||
port=os.getenv('VALKEY_PORT', 6379),
|
||||
db=os.getenv('VALKEY_DB', 0)
|
||||
db=os.getenv('VALKEY_DB', 0),
|
||||
)
|
||||
else:
|
||||
cache = AsyncCache(
|
||||
@@ -36,17 +49,17 @@ else:
|
||||
|
||||
if os.getenv("VALKEY_HOST", None) is not None:
|
||||
app.config.from_mapping(
|
||||
SESSION_TYPE="redis",
|
||||
SESSION_TYPE='redis',
|
||||
SESSION_PERMANENT=True,
|
||||
SESSION_USE_SIGNER=True,
|
||||
SESSION_REDIS = aioredis.Redis(
|
||||
SESSION_REDIS=aioredis.Redis(
|
||||
username=os.getenv('VALKEY_SESSION_USER', None),
|
||||
password=os.getenv('VALKEY_SESSION_PASSWORD', None),
|
||||
host=os.getenv("VALKEY_HOST"),
|
||||
port=os.getenv("VALKEY_PORT", 6379),
|
||||
db=os.getenv("VALKEY_DB", 0),
|
||||
decode_responses=True
|
||||
)
|
||||
host=os.getenv('VALKEY_HOST'),
|
||||
port=os.getenv('VALKEY_PORT', 6379),
|
||||
db=os.getenv('VALKEY_DB', 0),
|
||||
decode_responses=True,
|
||||
),
|
||||
)
|
||||
else:
|
||||
app.config.from_mapping(
|
||||
@@ -59,20 +72,55 @@ LIMITER = Limiter(
|
||||
custom_limit_key,
|
||||
app=app,
|
||||
storage_uri=(
|
||||
f"redis://{os.getenv('VALKEY_LIMITER_USER', '')}:{os.getenv('VALKEY_LIMITER_PASSWORD', '')}"
|
||||
f"@{os.getenv("VALKEY_HOST")}:{os.getenv('VALKEY_PORT', 6379)}/{os.getenv('VALKEY_DB', 0)}"
|
||||
) if os.getenv("VALKEY_HOST") else None,
|
||||
f'redis://{os.getenv('VALKEY_LIMITER_USER', '')}:{os.getenv('VALKEY_LIMITER_PASSWORD', '')}'
|
||||
f'@{os.getenv('VALKEY_HOST')}:{os.getenv('VALKEY_PORT', 6379)}/{os.getenv('VALKEY_DB', 0)}'
|
||||
)
|
||||
if os.getenv('VALKEY_HOST')
|
||||
else None,
|
||||
default_limits=[],
|
||||
strategy='moving-window'
|
||||
strategy='moving-window',
|
||||
)
|
||||
|
||||
convex_runtime = ConvexWorkerPool(os.getenv('CONVEX_URL'))
|
||||
app.convex_runtime = convex_runtime
|
||||
|
||||
orphan_retention_seconds = max(60, int(os.getenv('UPLOAD_ORPHAN_ID_RETENTION_SECONDS', '600')))
|
||||
if os.getenv('VALKEY_HOST', None) is not None:
|
||||
orphan_redis = aioredis.Redis(
|
||||
username=os.getenv('VALKEY_CACHE_USER', None),
|
||||
password=os.getenv('VALKEY_CACHE_PASSWORD', None),
|
||||
host=str(os.getenv('VALKEY_HOST')),
|
||||
port=int(os.getenv('VALKEY_PORT', 6379)),
|
||||
db=int(os.getenv('VALKEY_DB', 0)),
|
||||
decode_responses=False,
|
||||
)
|
||||
else:
|
||||
orphan_redis = None
|
||||
|
||||
app.orphan_storage_registry = OrphanStorageIdRegistry(
|
||||
retention_seconds=orphan_retention_seconds,
|
||||
redis_client=orphan_redis,
|
||||
)
|
||||
|
||||
@app.before_serving
|
||||
async def init_convex():
|
||||
app.convex = ConvexDB(os.getenv("CONVEX_URL"))
|
||||
await app.convex.connect()
|
||||
await convex_runtime.start()
|
||||
app.convex = ConvexDB(runtime=convex_runtime)
|
||||
|
||||
THE_IP_BOT_MANAGER.add_always_allowed_ip('127.0.0.1')
|
||||
THE_IP_BOT_MANAGER.add_always_allowed_ip(await get_my_ip_address())
|
||||
|
||||
local_docker_host_ip = get_local_ip_addresses()
|
||||
if local_docker_host_ip:
|
||||
base_ip = replace_last_ip_segment(local_docker_host_ip, 1)
|
||||
all_local_ips = generate_all_ips(base_ip)
|
||||
THE_IP_BOT_MANAGER.update_always_allowed_ip(all_local_ips)
|
||||
|
||||
@app.after_serving
|
||||
async def close_convex():
|
||||
if app.convex:
|
||||
await app.convex.close()
|
||||
await convex_runtime.stop()
|
||||
orphan_registry = getattr(app, 'orphan_storage_registry', None)
|
||||
if orphan_registry:
|
||||
await orphan_registry.close()
|
||||
await logger.shutdown()
|
||||
|
||||
+43
-31
@@ -1,28 +1,30 @@
|
||||
from my_helpers.ConvexDbBase import ConvexDbBase
|
||||
from my_helpers.db.convex.ConvexDbBase import ConvexDbBase
|
||||
from my_helpers.db.convex.ConvexRuntime import ConvexRuntime
|
||||
|
||||
from my_modules.app.logger import logger
|
||||
|
||||
from convex import ConvexError, ConvexExecutionError
|
||||
from datetime import datetime
|
||||
|
||||
class ConvexDB(ConvexDbBase):
|
||||
service_namespace = 'nanoshare'
|
||||
|
||||
def __init__(self, dsn:str):
|
||||
super().__init__(dsn=dsn, service=ConvexDB.service_namespace)
|
||||
def __init__(self, runtime:ConvexRuntime):
|
||||
super().__init__(
|
||||
runtime=runtime,
|
||||
service=ConvexDB.service_namespace
|
||||
)
|
||||
|
||||
# File Quary Functions
|
||||
async def get_file(self, file_id:str):
|
||||
data = await self.run_query_with_reconnection(
|
||||
self.client.query,
|
||||
f"{self.service_namespace}/files:getByFileId",
|
||||
data = await self.run_query(
|
||||
name='files:getByFileId',
|
||||
args={ 'file_id': file_id }
|
||||
)
|
||||
return data
|
||||
|
||||
async def get_files(self, user_id:str):
|
||||
data = await self.run_query_with_reconnection(
|
||||
self.client.query,
|
||||
f"{self.service_namespace}/files:getAllNotExpired",
|
||||
data = await self.run_query(
|
||||
name='files:getAllNotExpired',
|
||||
args={ 'user_id': user_id }
|
||||
)
|
||||
return [ {
|
||||
@@ -34,7 +36,7 @@ class ConvexDB(ConvexDbBase):
|
||||
"uploaded_at": int(x['uploaded_at']),
|
||||
} for x in data ]
|
||||
|
||||
async def add_file(self, file_name:str, file_size:str, note:str, content_type:str, expires_at:datetime, storage_id:str, user_id:str):
|
||||
async def add_file(self, file_name:str, file_size:str, note:str, content_type:str, expires_at:datetime|None, storage_id:str, user_id:str):
|
||||
args = {
|
||||
'file_name': file_name, 'file_size': file_size, 'content_type': content_type,
|
||||
'note': note,
|
||||
@@ -43,48 +45,58 @@ class ConvexDB(ConvexDbBase):
|
||||
if expires_at:
|
||||
args['expires_at'] = expires_at.isoformat()
|
||||
|
||||
data = await self.run_query_with_reconnection(
|
||||
self.client.mutation,
|
||||
f"{self.service_namespace}/files:addNewFile",
|
||||
data = await self.run_mutation(
|
||||
name='files:addNewFile',
|
||||
args=args,
|
||||
)
|
||||
return data
|
||||
|
||||
async def update_file(self, file_id:str, file_name:str, note:str, expires_at:datetime, user_id:str):
|
||||
await self.run_query_with_reconnection(
|
||||
self.client.mutation,
|
||||
f"{self.service_namespace}/files:updateFile",
|
||||
args={ 'file_id': file_id, 'file_name': file_name, 'note': note, 'expires_at': expires_at.isoformat(), 'user_id': user_id }
|
||||
async def update_file(self, file_id:str, file_name:str, note:str, expires_at:datetime|None, user_id:str):
|
||||
args = {
|
||||
'file_id': file_id,
|
||||
'file_name': file_name,
|
||||
'note': note,
|
||||
'user_id': user_id
|
||||
}
|
||||
if expires_at:
|
||||
args['expires_at'] = expires_at.isoformat()
|
||||
|
||||
await self.run_mutation(
|
||||
name='files:updateFile',
|
||||
args=args,
|
||||
)
|
||||
|
||||
async def delete_file(self, file_id:str, user_id:str):
|
||||
await self.run_query_with_reconnection(
|
||||
self.client.mutation,
|
||||
f"{self.service_namespace}/files:deleteFile",
|
||||
await self.run_mutation(
|
||||
name='files:deleteFile',
|
||||
args={ 'file_id': file_id, 'user_id': user_id }
|
||||
)
|
||||
|
||||
async def get_file_informations(self, file_id:str):
|
||||
pass
|
||||
async def get_file_informations(self, file_id:str, user_id:str):
|
||||
data = await self.run_query(
|
||||
name='files:getFileByIdAndUser',
|
||||
args={ 'file_id': file_id, 'user_id': user_id }
|
||||
)
|
||||
return data
|
||||
|
||||
# File Access Quary Functions
|
||||
async def add_file_access(self, file_id: str, ip_address:str, status:str, user_agent:str):
|
||||
data = await self.run_query_with_reconnection(
|
||||
self.client.mutation,
|
||||
f"{self.service_namespace}/access:addNewAccess",
|
||||
data = await self.run_mutation(
|
||||
name='access:addNewAccess',
|
||||
args={ 'file_id': file_id, 'ip_address': ip_address, 'user_agent': str(user_agent), 'status': status }
|
||||
)
|
||||
return data
|
||||
|
||||
async def get_all_access(self, user_id:str):
|
||||
data = await self.run_query_with_reconnection(
|
||||
self.client.query,
|
||||
f"{self.service_namespace}/access:getAllByUser",
|
||||
data = await self.run_query(
|
||||
name='access:getAllByUser',
|
||||
args={ 'user_id': user_id }
|
||||
)
|
||||
return data
|
||||
|
||||
async def get_file_access(self, file_id: str):
|
||||
async def get_file_access(self, file_id:str, user_id:str):
|
||||
return []
|
||||
|
||||
data = await self.run_query_with_reconnection(
|
||||
self.client.query_single,
|
||||
"""
|
||||
|
||||
+22
-145
@@ -1,150 +1,27 @@
|
||||
from my_modules.app.constens import SECRET_KEY
|
||||
from my_modules.app.constens import THE_IP_BOT_MANAGER
|
||||
from my_modules.app.logger import logger
|
||||
from my_modules.app.setup import LIMITER
|
||||
from my_modules.functions import get_ip
|
||||
from quart_common.web.auth import (
|
||||
get_auth_token,
|
||||
build_verify_token,
|
||||
build_token_required,
|
||||
)
|
||||
from quart_common.web.feature_flags import build_feature_flag_required
|
||||
from quart_common.web.decorators import (
|
||||
parse_request_data,
|
||||
format_response,
|
||||
build_apply_limit,
|
||||
login_required,
|
||||
)
|
||||
|
||||
from quart import jsonify, request, url_for, Response, current_app, session, abort
|
||||
from functools import wraps
|
||||
from datetime import datetime
|
||||
import asyncio, msgpack, json, jwt
|
||||
verify_token = build_verify_token(logger=logger)
|
||||
token_required = build_token_required(logger=logger, verify_token=verify_token)
|
||||
|
||||
def encode_object_default(obj):
|
||||
if isinstance(obj, datetime):
|
||||
return obj.strftime('%a, %d %b %Y %H:%M:%S %Z')
|
||||
raise TypeError(f"Type {type(obj)} not serializable")
|
||||
apply_limit = build_apply_limit(
|
||||
limiter=LIMITER,
|
||||
ip_bot_manager=THE_IP_BOT_MANAGER,
|
||||
)
|
||||
|
||||
# Helper function to extract the token
|
||||
async def get_auth_token():
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header:
|
||||
try:
|
||||
return auth_header.split(" ")[1]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
# Custom decorator for token validation
|
||||
def token_required(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
token = await get_auth_token()
|
||||
if not token:
|
||||
await logger.error('API Token is missing')
|
||||
return jsonify(error='Token is missing'), 400
|
||||
|
||||
try:
|
||||
decoded_payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
|
||||
if not await current_app.edgedb.check_if_refresh_token_exists_by_id(decoded_payload['refresh_id']):
|
||||
await logger.error(f'API Refresh Token not found: {decoded_payload['refresh_id']}')
|
||||
return jsonify(error='Refresh Token not found', msg='Please login again', url=url_for('login')), 403
|
||||
except jwt.ExpiredSignatureError:
|
||||
await logger.error('API Token has expired')
|
||||
return jsonify(error='Token has expired'), 401
|
||||
except jwt.InvalidTokenError:
|
||||
await logger.error('API Token is invalid')
|
||||
return jsonify(error='Token is invalid'), 401
|
||||
|
||||
return await func(user=decoded_payload, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
# Custom decorator for content type reading, convertig dict to response
|
||||
def parse_request_data(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
content_type = request.headers.get('Content-Type', '').lower()
|
||||
data = None
|
||||
body = await request.body
|
||||
|
||||
if body:
|
||||
if 'application/msgpack' in content_type or 'application/x-msgpack' in content_type:
|
||||
try:
|
||||
data = await asyncio.to_thread(msgpack.unpackb, body, raw=False)
|
||||
except Exception:
|
||||
return jsonify({'error': 'Invalid MessagePack'}), 400
|
||||
elif 'application/json' in content_type:
|
||||
data = await request.get_json(silent=True)
|
||||
if data is None:
|
||||
return jsonify({'error': 'Invalid JSON'}), 400
|
||||
else:
|
||||
if request.method in ['POST', 'PUT', 'PATCH', 'DELETE']:
|
||||
return jsonify({'error': 'Unsupported Content-Type'}), 415
|
||||
# else:
|
||||
# if request.method in ['POST', 'PUT', 'PATCH']:
|
||||
# return jsonify({'error': 'Empty request body'}), 400
|
||||
|
||||
return await func(data=data, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
def format_response(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Unpack result: (data), (data, status), (data, headers), (data, status, headers)
|
||||
data = None
|
||||
status = 200
|
||||
headers = {}
|
||||
|
||||
if isinstance(result, tuple):
|
||||
data = result[0]
|
||||
if len(result) == 2:
|
||||
if isinstance(result[1], dict):
|
||||
headers = result[1]
|
||||
else:
|
||||
status = result[1]
|
||||
elif len(result) == 3:
|
||||
status = result[1]
|
||||
headers = result[2]
|
||||
else:
|
||||
data = result
|
||||
|
||||
accept = request.headers.get('Accept', '').lower()
|
||||
if 'application/msgpack' in accept or 'application/x-msgpack' in accept:
|
||||
packed = await asyncio.to_thread(msgpack.packb, data, default=encode_object_default, use_bin_type=True)
|
||||
return Response(packed, content_type='application/msgpack', status=status, headers=headers)
|
||||
else:
|
||||
json_str = await asyncio.to_thread(json.dumps, data, ensure_ascii=False, default=encode_object_default)
|
||||
response = Response(json_str, status=status, content_type='application/json')
|
||||
response.headers.update(headers)
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
||||
# Custom decorator for adding limits for spezific methodes by endpoint
|
||||
def apply_limit(endpoint_name, limits:dict=None):
|
||||
def make_key_func(endpoint):
|
||||
def key_func():
|
||||
ip = get_ip()
|
||||
# if THE_IP_BOT_MANAGER.is_client_ip_always_allowed(ip):
|
||||
# return None # No key, no increment, no enforcement
|
||||
|
||||
# Combine endpoint name and HTTP method (and client IP) into the rate-limit key
|
||||
return f":{ip}:{endpoint}:{request.method}:"
|
||||
return key_func
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapped(*args, **kwargs):
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
rules = limits.get(endpoint_name)
|
||||
def dynamic_limit():
|
||||
if isinstance(rules, dict):
|
||||
return rules.get(request.method.upper(), "10000 per second")
|
||||
return rules or "10000 per second"
|
||||
|
||||
key_fn = make_key_func(endpoint_name)
|
||||
return LIMITER.limit(dynamic_limit, key_func=key_fn)(wrapped)
|
||||
return decorator
|
||||
|
||||
# Check if User is loggedin
|
||||
def login_required(func):
|
||||
@wraps(func)
|
||||
async def decorated_function(*args, **kwargs):
|
||||
user_session = session.get('user')
|
||||
if user_session is None:
|
||||
abort(401)
|
||||
|
||||
return await func(user=user_session, *args, **kwargs)
|
||||
return decorated_function
|
||||
feature_flag_required = build_feature_flag_required(
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
import re
|
||||
|
||||
PRESET_H = re.compile(r"^(\d+)h$")
|
||||
PRESET_D = re.compile(r"^(\d+)d$")
|
||||
|
||||
def parse_expires(value: str | None) -> datetime | None:
|
||||
"""Parse expiration presets or ISO datetime."""
|
||||
if not value or value == "never":
|
||||
return None
|
||||
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if m := PRESET_H.match(value):
|
||||
return datetime.now(timezone.utc) + timedelta(hours=int(m.group(1)))
|
||||
if m := PRESET_D.match(value):
|
||||
return datetime.now(timezone.utc) + timedelta(days=int(m.group(1)))
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def ensure_utc(dt:datetime):
|
||||
"""Ensure a timezone-aware UTC datetime or None."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
def iso_stamp_filename(prefix: str, ext: str) -> str:
|
||||
"""Generate timestamped filename, e.g. pasted-2025-10-23T121212Z.png"""
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
ts = ts.replace(":", "").split(".")[0]
|
||||
if ts.endswith("+00:00"):
|
||||
ts = ts.replace("+00:00", "Z")
|
||||
return f"{prefix}-{ts}.{ext}"
|
||||
|
||||
def format_size(num_bytes: int) -> str:
|
||||
"""Return a human-readable file size (e.g., '2.3 MB', '10 Bytes')."""
|
||||
if num_bytes < 1024:
|
||||
return f"{num_bytes} Byte{'s' if num_bytes != 1 else ''}"
|
||||
|
||||
units = ["KB", "MB", "GB", "TB", "PB", "EB"]
|
||||
size = float(num_bytes)
|
||||
for unit in units:
|
||||
size /= 1024.0
|
||||
if size < 1024.0 or unit == units[-1]:
|
||||
# 1 decimal place; drop trailing .0 (optional)
|
||||
val = f"{size:.1f}"
|
||||
if val.endswith(".0"):
|
||||
val = val[:-2]
|
||||
return f"{val} {unit}"
|
||||
return f"{num_bytes} Bytes" # fallback
|
||||
|
||||
+11
-43
@@ -1,39 +1,15 @@
|
||||
from quart import has_request_context, request, has_websocket_context, websocket
|
||||
from quart_common.web.request import (
|
||||
get_ip,
|
||||
get_my_ip_address,
|
||||
get_local_ip_addresses,
|
||||
generate_all_ips,
|
||||
replace_last_ip_segment,
|
||||
get_request_context,
|
||||
is_valid_uuid,
|
||||
)
|
||||
from quart_common.web.env import is_development_environment, is_testing_environment
|
||||
|
||||
from flask_limiter import Limiter
|
||||
import subprocess, aiohttp
|
||||
|
||||
# Get IPs
|
||||
def get_ip():
|
||||
if has_request_context():
|
||||
xff = request.headers.get("X-Forwarded-For", "")
|
||||
return xff.split(",")[0].strip() if xff else request.remote_addr
|
||||
elif has_websocket_context():
|
||||
xff = websocket.headers.get("X-Forwarded-For", "")
|
||||
return xff.split(",")[0].strip() if xff else websocket.remote_addr
|
||||
return None # No active request or websocket context
|
||||
|
||||
async def get_my_ip_address():
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get("https://ipinfo.io/ip") as response:
|
||||
if response.status == 200:
|
||||
return await response.text()
|
||||
raise aiohttp.ClientError(f'Could not get IP: {response.status} {await response.text()}')
|
||||
|
||||
def get_local_ip_addresses():
|
||||
try:
|
||||
result = subprocess.run(['hostname', '-I'], capture_output=True, text=True)
|
||||
first_ip = result.stdout.strip().split()[0]
|
||||
return first_ip
|
||||
except subprocess.CalledProcessError as e:
|
||||
return None
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def generate_all_ips(base_ip:str) -> set:
|
||||
ips = set()
|
||||
for i in range(1, 255): # 1 to 254 inclusive
|
||||
ips.add(replace_last_ip_segment(base_ip, i))
|
||||
return ips
|
||||
|
||||
# Limiter Key Gen
|
||||
def custom_limit_key():
|
||||
@@ -53,11 +29,3 @@ def enforce_custom_limit(limiter:Limiter, key:str, limit_count: int = 3, window_
|
||||
current = limiter.storage.incr(key, expiry=window_sec)
|
||||
if current > limit_count:
|
||||
raise LookupError("To Many 404 Requests")
|
||||
|
||||
## Helper
|
||||
def replace_last_ip_segment(ip:str, new_value:str="1") -> str:
|
||||
parts = ip.strip().split('.')
|
||||
if len(parts) == 4:
|
||||
parts[-1] = str(new_value)
|
||||
return '.'.join(parts)
|
||||
raise ValueError("Invalid IP address format")
|
||||
|
||||
+14
-18
@@ -1,27 +1,23 @@
|
||||
from routes.handeling.errorsAndBots import maybe_a_hacker
|
||||
|
||||
from my_modules.app.constens import THE_IP_BOT_MANAGER, SKIP_PATH_PREFIXES, SKIP_PATHS
|
||||
from my_modules.app.logger import logger
|
||||
from my_modules.functions import get_ip
|
||||
from my_modules.app.setup import app
|
||||
from quart_common.web.security_middleware import register_security_middleware
|
||||
|
||||
from quart import request, render_template, current_app, session
|
||||
from quart import session
|
||||
from datetime import datetime
|
||||
|
||||
@app.before_request
|
||||
async def custom_middleware():
|
||||
if session.get('user'): # only if session already has data, update redis expire time
|
||||
session.permanent = True
|
||||
|
||||
client_ip = get_ip()
|
||||
path = request.path
|
||||
method = request.method
|
||||
|
||||
# Skip allowed IPs or non-critical assets
|
||||
if (
|
||||
"favicon" in path
|
||||
or "static" in path
|
||||
):
|
||||
return
|
||||
|
||||
await logger.info(f"{method} | {client_ip} had accessed the Side {path}")
|
||||
custom_middleware = register_security_middleware(
|
||||
app,
|
||||
logger=logger,
|
||||
ip_bot_manager=THE_IP_BOT_MANAGER,
|
||||
get_ip=get_ip,
|
||||
maybe_hacker_fn=maybe_a_hacker,
|
||||
skip_paths=SKIP_PATHS,
|
||||
skip_path_prefixes=SKIP_PATH_PREFIXES,
|
||||
)
|
||||
|
||||
@app.context_processor
|
||||
async def inject_context_data():
|
||||
|
||||
+14
-52
@@ -1,66 +1,28 @@
|
||||
[project]
|
||||
name = "simple-picoshare"
|
||||
version = "0.1.0"
|
||||
name = "nanoshare"
|
||||
version = "1.21.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aiofiles==25.1.0",
|
||||
"aiohappyeyeballs==2.6.1",
|
||||
"aiohttp==3.13.1",
|
||||
"aiohttp==3.13.3",
|
||||
"aiologger==0.7.0",
|
||||
"aiosignal==1.4.0",
|
||||
"anyio==4.11.0",
|
||||
"attrs==25.4.0",
|
||||
"authlib==1.6.5",
|
||||
"blinker==1.9.0",
|
||||
"certifi==2025.10.5",
|
||||
"cffi==2.0.0",
|
||||
"click==8.3.0",
|
||||
"convex==0.7.0",
|
||||
"cryptography==46.0.3",
|
||||
"deprecated==1.2.18",
|
||||
"dotenv==0.9.9",
|
||||
"flask==3.1.2",
|
||||
"flask-limiter==4.0.0",
|
||||
"frozenlist==1.8.0",
|
||||
"h11==0.16.0",
|
||||
"h2==4.3.0",
|
||||
"hpack==4.1.0",
|
||||
"httpcore==1.0.9",
|
||||
"flask-limiter==4.1.1",
|
||||
"httpx==0.28.1",
|
||||
"hypercorn==0.17.3",
|
||||
"hyperframe==6.1.0",
|
||||
"idna==3.11",
|
||||
"itsdangerous==2.2.0",
|
||||
"jinja2==3.1.6",
|
||||
"limits==5.6.0",
|
||||
"markdown-it-py==4.0.0",
|
||||
"markupsafe==3.0.3",
|
||||
"mdurl==0.1.2",
|
||||
"msgpack==1.1.2",
|
||||
"multidict==6.7.0",
|
||||
"ordered-set==4.1.0",
|
||||
"packaging==25.0",
|
||||
"pip-autoremove==0.10.0",
|
||||
"priority==2.0.0",
|
||||
"propcache==0.4.1",
|
||||
"pycparser==2.23",
|
||||
"pygments==2.19.2",
|
||||
"pyjwt==2.10.1",
|
||||
"python-dotenv==1.1.1",
|
||||
"hypercorn==0.18.0",
|
||||
"joserfc>=1.6.5",
|
||||
"python-dotenv==1.2.2",
|
||||
"quart==0.20.0",
|
||||
"quart-flask-patch==0.3.0",
|
||||
"quart-session",
|
||||
"redis==7.0.0",
|
||||
"rich==14.2.0",
|
||||
"setuptools==80.9.0",
|
||||
"sniffio==1.3.1",
|
||||
"typing-extensions==4.15.0",
|
||||
"werkzeug==3.1.3",
|
||||
"wrapt==1.17.3",
|
||||
"wsproto==1.2.0",
|
||||
"yarl==1.22.0",
|
||||
"redis==7.4.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = [
|
||||
"tests",
|
||||
"quart_common/tests",
|
||||
]
|
||||
|
||||
[tool.uv.workspace]
|
||||
|
||||
Submodule
+1
Submodule quart_common added at 77823f57d1
+24
-26
@@ -1,57 +1,55 @@
|
||||
aiofiles==25.1.0
|
||||
aiohappyeyeballs==2.6.1
|
||||
aiohttp==3.13.1
|
||||
aiohttp==3.13.3
|
||||
aiologger==0.7.0
|
||||
aiosignal==1.4.0
|
||||
anyio==4.11.0
|
||||
attrs==25.4.0
|
||||
authlib==1.6.5
|
||||
anyio==4.13.0
|
||||
attrs==26.1.0
|
||||
authlib==1.6.9
|
||||
blinker==1.9.0
|
||||
certifi==2025.10.5
|
||||
certifi==2026.2.25
|
||||
cffi==2.0.0
|
||||
click==8.3.0
|
||||
click==8.3.1
|
||||
convex==0.7.0
|
||||
cryptography==46.0.3
|
||||
deprecated==1.2.18
|
||||
cryptography==46.0.6
|
||||
deprecated==1.3.1
|
||||
dotenv==0.9.9
|
||||
flask==3.1.2
|
||||
flask-limiter==4.0.0
|
||||
flask==3.1.3
|
||||
flask-limiter==4.1.1
|
||||
frozenlist==1.8.0
|
||||
gel==3.1.0
|
||||
h11==0.16.0
|
||||
h2==4.3.0
|
||||
hpack==4.1.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
hypercorn==0.17.3
|
||||
hypercorn==0.18.0
|
||||
hyperframe==6.1.0
|
||||
idna==3.11
|
||||
itsdangerous==2.2.0
|
||||
jinja2==3.1.6
|
||||
limits==5.6.0
|
||||
limits==5.8.0
|
||||
markdown-it-py==4.0.0
|
||||
markupsafe==3.0.3
|
||||
mdurl==0.1.2
|
||||
msgpack==1.1.2
|
||||
multidict==6.7.0
|
||||
multidict==6.7.1
|
||||
ordered-set==4.1.0
|
||||
packaging==25.0
|
||||
pip-autoremove==0.10.0
|
||||
packaging==26.0
|
||||
priority==2.0.0
|
||||
propcache==0.4.1
|
||||
pycparser==2.23
|
||||
pycparser==3.0
|
||||
pygments==2.19.2
|
||||
pyjwt==2.10.1
|
||||
python-dotenv==1.1.1
|
||||
pyjwt==2.12.1
|
||||
python-dotenv==1.2.2
|
||||
quart==0.20.0
|
||||
quart-flask-patch==0.3.0
|
||||
-e ./quart-session
|
||||
redis==7.0.0
|
||||
rich==14.2.0
|
||||
setuptools==80.9.0
|
||||
redis==7.4.0
|
||||
rich==14.3.3
|
||||
setuptools==82.0.1
|
||||
sniffio==1.3.1
|
||||
typing-extensions==4.15.0
|
||||
werkzeug==3.1.3
|
||||
wrapt==1.17.3
|
||||
wsproto==1.2.0
|
||||
yarl==1.22.0
|
||||
werkzeug==3.1.7
|
||||
wrapt==2.1.2
|
||||
wsproto==1.3.2
|
||||
yarl==1.23.0
|
||||
|
||||
+18
-1
@@ -1,6 +1,23 @@
|
||||
from my_modules.app.setup import cache, LIMITER
|
||||
from my_modules.app.constens import API_GROUP
|
||||
from my_modules.app.logger import logger
|
||||
from .handeling.errorsAndBots import auth_error
|
||||
|
||||
from .handeling.basics import basic_bp
|
||||
from .auth.login import auth_login_bp
|
||||
|
||||
from quart_common.routes.login import create_auth_login_blueprint
|
||||
auth_login_bp = create_auth_login_blueprint(
|
||||
cache=cache,
|
||||
limiter=LIMITER,
|
||||
logger=logger,
|
||||
api_group=API_GROUP,
|
||||
auth_error_fn=auth_error,
|
||||
redirect_endpoint='side_main.index',
|
||||
)
|
||||
|
||||
from .side.main import side_main_bp
|
||||
|
||||
from .side.upload import upload_bp
|
||||
|
||||
# Health
|
||||
from .api.health import health_bp
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from my_modules.decoratory.header import format_response, feature_flag_required
|
||||
from my_modules.app.setup import LIMITER
|
||||
from my_modules.app.logger import logger
|
||||
from quart_common.web.wide_event import add_wide_event_context
|
||||
|
||||
from quart import Blueprint, current_app
|
||||
|
||||
health_bp = Blueprint("health", __name__)
|
||||
|
||||
@health_bp.route("/convex", methods=["GET"])
|
||||
@LIMITER.limit("30 per minute")
|
||||
@feature_flag_required("convex_health", fallback=False, status_code=404)
|
||||
@format_response
|
||||
async def get_convex_health_for_api():
|
||||
add_wide_event_context(health={"check": "convex"})
|
||||
runtime = getattr(current_app, "convex_runtime", None)
|
||||
if runtime is None:
|
||||
add_wide_event_context(health={"status": "unavailable", "reason": "runtime_missing"})
|
||||
return {
|
||||
"status": "unavailable",
|
||||
"description": "Convex runtime is not attached to app",
|
||||
}, 503
|
||||
|
||||
is_alive = await runtime.is_alive()
|
||||
metrics = runtime.get_metrics()
|
||||
add_wide_event_context(health={"status": "ok" if is_alive else "unhealthy"})
|
||||
|
||||
return {
|
||||
"alive": is_alive,
|
||||
"metrics": metrics,
|
||||
}, 200 if is_alive else 503
|
||||
@@ -0,0 +1,119 @@
|
||||
'''ServiceLink mesh endpoint for the picoshare/NanoShare node.
|
||||
|
||||
Lets other nodes (browser-cli, website) push files in and read file metadata
|
||||
over the shared servicelink envelope at POST /rpc, alongside the existing web
|
||||
UI and /api routes.
|
||||
|
||||
Every call needs a bearer token carrying the `mesh` scope; the endpoint is rate
|
||||
limited and body-size capped. Keep /rpc on the internal node network.
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
from quart import current_app
|
||||
|
||||
from my_modules.app.setup import LIMITER
|
||||
from my_modules.expiry import ensure_utc, parse_expires
|
||||
from my_modules.file_meta import format_size, iso_stamp_filename
|
||||
from servicelink import InvalidParams, NotFound, Router, Unauthorized, any_verifier, bearer_verifier, create_link_blueprint, shared_secret_verifier
|
||||
|
||||
MAX_RPC_BODY = 16 * 1024 * 1024
|
||||
MESH_SCOPE = 'mesh'
|
||||
|
||||
router = Router('picoshare')
|
||||
|
||||
def _user_id(ctx):
|
||||
if ctx.principal is None:
|
||||
raise Unauthorized('authentication required')
|
||||
return ctx.principal.subject
|
||||
|
||||
@router.method('files.upload')
|
||||
async def files_upload(params, ctx):
|
||||
user_id = _user_id(ctx)
|
||||
text = params.get('text')
|
||||
content_b64 = params.get('content_b64')
|
||||
if content_b64:
|
||||
data = base64.b64decode(content_b64)
|
||||
content_type = params.get('content_type') or 'application/octet-stream'
|
||||
default_ext = 'bin'
|
||||
elif text is not None:
|
||||
data = text.encode('utf-8')
|
||||
content_type = 'text/plain'
|
||||
default_ext = 'txt'
|
||||
else:
|
||||
raise InvalidParams('provide text or content_b64')
|
||||
|
||||
file_name = params.get('file_name') or iso_stamp_filename('mesh', default_ext)
|
||||
storage_id = await current_app.convex.send_to_storage(data=data, content_type=content_type)
|
||||
await current_app.convex.add_file(
|
||||
file_name=file_name,
|
||||
file_size=format_size(len(data)),
|
||||
note=params.get('note', ''),
|
||||
content_type=content_type,
|
||||
expires_at=ensure_utc(parse_expires(params.get('expires', ''))),
|
||||
storage_id=storage_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
return {'file_name': file_name, 'size': len(data), 'content_type': content_type}
|
||||
|
||||
@router.method('files.list')
|
||||
async def files_list(params, ctx):
|
||||
return {'files': await current_app.convex.get_files(user_id=_user_id(ctx))}
|
||||
|
||||
@router.method('files.get')
|
||||
async def files_get(params, ctx):
|
||||
file_id = params.get('file_id')
|
||||
if not file_id:
|
||||
raise InvalidParams('file_id is required')
|
||||
meta = await current_app.convex.get_file(file_id)
|
||||
if not meta:
|
||||
raise NotFound('no such file', data={'file_id': file_id})
|
||||
return meta
|
||||
|
||||
@router.method('files.info')
|
||||
async def files_info(params, ctx):
|
||||
file_id = params.get('file_id')
|
||||
if not file_id:
|
||||
raise InvalidParams('file_id is required')
|
||||
return await current_app.convex.get_file_informations(file_id, _user_id(ctx))
|
||||
|
||||
@router.method('files.update')
|
||||
async def files_update(params, ctx):
|
||||
file_id = params.get('file_id')
|
||||
file_name = params.get('file_name')
|
||||
if not file_id or not file_name:
|
||||
raise InvalidParams('file_id and file_name are required')
|
||||
await current_app.convex.update_file(
|
||||
file_id=file_id,
|
||||
file_name=file_name,
|
||||
note=params.get('note', ''),
|
||||
expires_at=ensure_utc(parse_expires(params.get('expires', ''))),
|
||||
user_id=_user_id(ctx),
|
||||
)
|
||||
return {'updated': True}
|
||||
|
||||
@router.method('files.delete')
|
||||
async def files_delete(params, ctx):
|
||||
file_id = params.get('file_id')
|
||||
if not file_id:
|
||||
raise InvalidParams('file_id is required')
|
||||
await current_app.convex.delete_file(file_id, _user_id(ctx))
|
||||
return {'deleted': True}
|
||||
|
||||
async def _decode_access_token(token):
|
||||
payload = await current_app.convex.decode_access_token_payload(access_token=token)
|
||||
if not payload or payload.get('error') or not payload.get('sub'):
|
||||
raise ValueError((payload or {}).get('error', 'invalid token'))
|
||||
return payload
|
||||
|
||||
def _build_verify():
|
||||
# Accept a JWT access token with the mesh scope (public path) OR, on the
|
||||
# trusted Docker network, a static shared secret from SERVICELINK_MESH_SECRET.
|
||||
jwt = bearer_verifier(_decode_access_token, require_scope=MESH_SCOPE)
|
||||
secret = os.getenv('SERVICELINK_MESH_SECRET')
|
||||
return any_verifier(shared_secret_verifier(secret, scopes=(MESH_SCOPE,)), jwt) if secret else jwt
|
||||
|
||||
verify = _build_verify()
|
||||
link_bp = create_link_blueprint(router, verify=verify, limiter=LIMITER.limit('30 per minute'), max_body=MAX_RPC_BODY)
|
||||
@@ -1,139 +0,0 @@
|
||||
from my_modules.app.setup import cache, LIMITER
|
||||
from my_modules.app.constens import API_GROUP
|
||||
from my_modules.app.logger import logger
|
||||
|
||||
from quart import Blueprint, redirect, url_for, session, request, render_template, abort, make_response, current_app
|
||||
|
||||
from authlib.jose import JsonWebKey, JoseError, jwt as authlib_jwt
|
||||
from authlib.integrations.httpx_client import AsyncOAuth2Client
|
||||
from jwt import InvalidTokenError
|
||||
|
||||
import httpx, uuid, os
|
||||
|
||||
auth_login_bp = Blueprint('auth_login', __name__)
|
||||
|
||||
# OAuth
|
||||
OIDC_CLIENT_ID = os.getenv('OIDC_CLIENT_ID', '')
|
||||
OIDC_CLIENT_SECRET = os.getenv('OIDC_CLIENT_SECRET', '')
|
||||
OIDC_METADATA_URL = os.getenv('OIDC_METADATA_URL', '')
|
||||
REDIRECT_URI_SCHEME = os.getenv('REDIRECT_URI_SCHEME', 'http')
|
||||
|
||||
async def get_oidc_metadata():
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(OIDC_METADATA_URL)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@auth_login_bp.route('/', methods=['GET'])
|
||||
@LIMITER.limit("5 per minute; 30 per hour")
|
||||
async def login():
|
||||
metadata = await get_oidc_metadata()
|
||||
|
||||
auth_id = request.cookies.get('auth_id') or str(uuid.uuid4())
|
||||
nonce = str(uuid.uuid4())
|
||||
|
||||
await cache.set(f"oauth:nonce:{auth_id}", nonce, ttl=3603)
|
||||
|
||||
client = AsyncOAuth2Client(
|
||||
client_id=OIDC_CLIENT_ID,
|
||||
client_secret=OIDC_CLIENT_SECRET,
|
||||
redirect_uri=url_for('auth_login.auth_callback', _external=True, _scheme=REDIRECT_URI_SCHEME),
|
||||
scope='openid profile email',
|
||||
)
|
||||
|
||||
uri, state = client.create_authorization_url(
|
||||
metadata['authorization_endpoint'],
|
||||
nonce=nonce,
|
||||
state=auth_id,
|
||||
)
|
||||
|
||||
response = await make_response(redirect(uri))
|
||||
response.set_cookie('auth_id', auth_id, max_age=3600, httponly=True, secure=True, samesite='Lax')
|
||||
return response
|
||||
|
||||
@auth_login_bp.route('/logout', methods=['GET'])
|
||||
@LIMITER.limit("10 per minute")
|
||||
async def logout():
|
||||
user = session.get('user')
|
||||
if user:
|
||||
await logger.info(f'logging out user: {user}')
|
||||
session.pop('user', None)
|
||||
|
||||
session.clear()
|
||||
return redirect(url_for('side_main.index'))
|
||||
|
||||
@auth_login_bp.route('/callback', methods=['GET'])
|
||||
@LIMITER.limit("5 per minute")
|
||||
async def auth_callback():
|
||||
try:
|
||||
auth_id = request.args.get('state')
|
||||
code = request.args.get('code')
|
||||
nonce = await cache.get(f"oauth:nonce:{auth_id}")
|
||||
|
||||
if not nonce:
|
||||
await logger.error('Nonce not found')
|
||||
return await render_template('views/api/token.htm', error='Nonce not found'), 400
|
||||
|
||||
await cache.delete(f"oauth:nonce:{auth_id}")
|
||||
|
||||
metadata = await get_oidc_metadata()
|
||||
client = AsyncOAuth2Client(
|
||||
client_id=OIDC_CLIENT_ID,
|
||||
client_secret=OIDC_CLIENT_SECRET,
|
||||
redirect_uri=url_for('auth_login.auth_callback', _external=True, _scheme=REDIRECT_URI_SCHEME),
|
||||
scope='openid profile email',
|
||||
)
|
||||
|
||||
# Exchange code for token
|
||||
token = await client.fetch_token(
|
||||
metadata['token_endpoint'],
|
||||
code=code,
|
||||
grant_type='authorization_code'
|
||||
)
|
||||
await logger.debug(f'Auth Callback | token: {token}')
|
||||
|
||||
# Decode ID token
|
||||
id_token = token.get('id_token')
|
||||
if not id_token:
|
||||
await logger.error('ID token missing in OAuth response')
|
||||
return await render_template('views/api/token.htm', error='ID token missing'), 400
|
||||
|
||||
# Fetch the JWKs to verify signature
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
jwks_resp = await http_client.get(metadata['jwks_uri'])
|
||||
jwks = jwks_resp.json()
|
||||
|
||||
keys = JsonWebKey.import_key_set(jwks)
|
||||
claims = authlib_jwt.decode(id_token, key=keys)
|
||||
|
||||
try:
|
||||
claims.validate_aud()
|
||||
claims.validate()
|
||||
except JoseError as e:
|
||||
await logger.error(f"JWT validation error: {e}")
|
||||
return await render_template('views/api/token.htm', error=str(e)), 400
|
||||
|
||||
if claims.get('nonce') != nonce:
|
||||
await logger.error('Nonce mismatch in ID token')
|
||||
return await render_template('views/api/token.htm', error='Invalid nonce'), 400
|
||||
|
||||
await logger.info(f'Auth Callback | user_info: {claims}')
|
||||
|
||||
if API_GROUP not in claims.get('groups', []):
|
||||
await logger.error("You don't have Permissions to Access this API")
|
||||
return await render_template('views/api/token.htm', error="You don't have Permissions to Access this API"), 403
|
||||
|
||||
session['user'] = claims
|
||||
response = await make_response(redirect(url_for('side_main.index')))
|
||||
response.set_cookie('auth_id', '', max_age=0, httponly=True, secure=True, samesite='Lax')
|
||||
return response
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
await logger.error(f"HTTP error during token exchange: {e}")
|
||||
return await render_template('views/api/token.htm', error="Token exchange failed"), 500
|
||||
except InvalidTokenError as e:
|
||||
await logger.error(f"Invalid ID Token: {e}")
|
||||
return await render_template('views/api/token.htm', error="Invalid ID Token"), 400
|
||||
except Exception as e:
|
||||
await logger.exception("Unknown error during OAuth callback")
|
||||
return await render_template('views/api/token.htm', error=str(e)), 500
|
||||
@@ -1,11 +1,17 @@
|
||||
from my_modules.app.setup import LIMITER
|
||||
from my_modules.functions import is_valid_uuid
|
||||
|
||||
from quart import Blueprint, send_from_directory, render_template, current_app, Response, redirect
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from quart import Blueprint, send_from_directory, current_app, Response, redirect, abort, request
|
||||
|
||||
basic_bp = Blueprint('basic', __name__)
|
||||
|
||||
@basic_bp.route('/favicon', methods=['GET'])
|
||||
@basic_bp.route('/favicon.ico', methods=['GET'])
|
||||
@basic_bp.route('/favicon-32x32.png', methods=['GET'])
|
||||
@basic_bp.route('/favicon.png', methods=['GET'])
|
||||
@basic_bp.route('/res/favicon.ico', methods=['GET'])
|
||||
@basic_bp.route('/favicon', methods=['GET'])
|
||||
@LIMITER.exempt
|
||||
async def favicon():
|
||||
file_data = await current_app.convex.get_current_favicon()
|
||||
@@ -18,7 +24,23 @@ async def robots():
|
||||
|
||||
@basic_bp.route("/storage/<path:file_id>")
|
||||
async def convex_storage_proxy(file_id:str):
|
||||
return Response(
|
||||
current_app.convex.stream_from_storage(file_id, add_api_path=True),
|
||||
mimetype="application/octet-stream"
|
||||
)
|
||||
if not is_valid_uuid(file_id):
|
||||
return abort(404, "Not a valid uuid")
|
||||
|
||||
query_keys = set(request.args.keys())
|
||||
if query_keys - {"component"}:
|
||||
return abort(400, "Only the component query parameter is allowed")
|
||||
|
||||
component_values = request.args.getlist("component")
|
||||
if len(component_values) > 1:
|
||||
return abort(400, "Only one component query parameter is allowed")
|
||||
|
||||
storage_file_id = file_id
|
||||
if component_values:
|
||||
storage_file_id = f"{file_id}?{urlencode({'component': component_values[0]})}"
|
||||
|
||||
stream, headers = await current_app.convex.open_storage_stream(storage_file_id, add_api_path=True)
|
||||
if 'Content-Type' not in headers:
|
||||
headers['Content-Type'] = 'application/octet-stream'
|
||||
|
||||
return Response(stream, headers=headers)
|
||||
|
||||
@@ -1,18 +1,141 @@
|
||||
from my_modules.app.constens import (
|
||||
BLOCKED_IPS_ACCESSING_TIMES,
|
||||
BLOCKED_IPS_STORED_TIMEFRAME,
|
||||
)
|
||||
from my_modules.app.setup import app, LIMITER
|
||||
from my_modules.app.logger import logger
|
||||
|
||||
from quart import request, render_template, jsonify, current_app, make_response, redirect, url_for
|
||||
from my_modules.functions import get_ip, enforce_custom_limit
|
||||
from quart import request, render_template, jsonify, current_app, make_response, g
|
||||
from werkzeug.exceptions import HTTPException
|
||||
from my_modules.functions import (
|
||||
get_ip,
|
||||
enforce_custom_limit,
|
||||
get_request_context,
|
||||
)
|
||||
from quart_common.web.env import is_development_environment
|
||||
from quart_common.web.wide_event import add_httpx_error_wide_event_context, add_wide_event_context, httpx_error_wide_event_context, log_when_wide_event_disabled
|
||||
import asyncio, os
|
||||
import httpx
|
||||
|
||||
CONVEX_ERROR_TRACKING_TIMEOUT_SECS = float(os.getenv("CONVEX_ERROR_TRACKING_TIMEOUT_SECS", "120"))
|
||||
DATABASE_RETRY_AFTER_SECS = int(os.getenv("DATABASE_RETRY_AFTER_SECS", "30"))
|
||||
|
||||
CONVEX_DOWN_ERROR_MESSAGES = (
|
||||
"convex runtime not running",
|
||||
"convex job timed out",
|
||||
"convex worker",
|
||||
"connection refused",
|
||||
"connect call failed",
|
||||
"all connection attempts failed",
|
||||
"name or service not known",
|
||||
"temporary failure in name resolution",
|
||||
)
|
||||
|
||||
def _is_convex_unavailable_error(error: BaseException) -> bool:
|
||||
if isinstance(error, (asyncio.TimeoutError, httpx.RequestError)):
|
||||
return True
|
||||
|
||||
error_text = str(error).casefold()
|
||||
return any(message in error_text for message in CONVEX_DOWN_ERROR_MESSAGES)
|
||||
|
||||
def _is_convex_timeout_error(error: BaseException) -> bool:
|
||||
if isinstance(error, (asyncio.TimeoutError, httpx.TimeoutException)):
|
||||
return True
|
||||
|
||||
error_text = str(error).casefold()
|
||||
return "timeout" in error_text or "timed out" in error_text
|
||||
|
||||
def _convex_unavailable_status_code(error: BaseException) -> int:
|
||||
return 504 if _is_convex_timeout_error(error) else 503
|
||||
|
||||
async def _safe_convex_error_tracking(label: str, awaitable):
|
||||
try:
|
||||
return await asyncio.wait_for(awaitable, timeout=CONVEX_ERROR_TRACKING_TIMEOUT_SECS)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError) as error:
|
||||
add_wide_event_context(error_tracking={f"{label}_failed": True}, error={"type": type(error).__name__, "message": str(error)})
|
||||
await log_when_wide_event_disabled(logger, "warning", f"[ERROR_TRACKING] Convex {label} failed ({type(error).__name__}); rendering error response anyway")
|
||||
return None
|
||||
except Exception as error:
|
||||
add_wide_event_context(error_tracking={f"{label}_failed": True}, error={"type": type(error).__name__, "message": str(error)})
|
||||
await log_when_wide_event_disabled(logger, "error", f"[ERROR_TRACKING] Convex {label} failed: {error}")
|
||||
return None
|
||||
|
||||
IGNORED_404_PATHS = [
|
||||
"/.well-known/",
|
||||
]
|
||||
|
||||
IGNORE_CONTAIN_404_PATHS = [
|
||||
"/.htaccess",
|
||||
]
|
||||
|
||||
FEATURE_FLAG_DISABLED_PREFIX = "feature_flag_disabled:"
|
||||
|
||||
AUTH_STATUS_TITLES = {
|
||||
400: '400 - OAuth Time Paradox',
|
||||
401: '401 - Not Authenticated',
|
||||
403: '403 - Access Forbidden',
|
||||
500: '500 - Auth Reactor Meltdown',
|
||||
504: '504 - Auth Gateway Timeout',
|
||||
}
|
||||
|
||||
AUTH_STATUS_MESSAGES = {
|
||||
400: (
|
||||
'The fox courier dropped your login form in a puddle before it reached the gatekeeper. '
|
||||
'Please send it again with all required fields so the checkpoint can read it.'
|
||||
),
|
||||
401: (
|
||||
'The fox guards checked your badge and it did not pass the sniff test this round. '
|
||||
'Please sign in again so they can issue a fresh one.'
|
||||
),
|
||||
403: (
|
||||
'The fox guards found your badge valid, but this den is still off-limits for your current role. '
|
||||
'If you should have access, ask an admin to update your permissions.'
|
||||
),
|
||||
500: (
|
||||
'The auth engine coughed up a spark and the fox mechanics are tightening bolts right now. '
|
||||
'Please try again in a moment while they get the reactor stable.'
|
||||
),
|
||||
504: (
|
||||
'The fox guards are still waiting for the auth mothership to answer the walkie-talkie. '
|
||||
'Please try again in a moment before they start howling at the server rack.'
|
||||
),
|
||||
}
|
||||
|
||||
async def auth_error(message:str, status_code:int=400):
|
||||
context = get_request_context()
|
||||
if status_code in AUTH_STATUS_MESSAGES:
|
||||
funny_message = AUTH_STATUS_MESSAGES[status_code]
|
||||
else:
|
||||
funny_message = (
|
||||
'The fox guards tripped over a cable while checking your badge. '
|
||||
'Authentication failed. Please try again or contact an administrator.'
|
||||
)
|
||||
|
||||
add_wide_event_context(auth={"operation_status": "error"}, error={"type": "AuthenticationError", "message": message})
|
||||
await log_when_wide_event_disabled(logger, "error", f"[AUTH:{status_code}] {message}")
|
||||
|
||||
if context and context.path.startswith("/api"):
|
||||
return jsonify({"error": "Authentication Error", "message": funny_message}), status_code
|
||||
|
||||
return await render_template('views/basics/error.htm',
|
||||
title='Authentication Error',
|
||||
header={'title': AUTH_STATUS_TITLES.get(status_code, f'{status_code} - Authentication Error'), 'message': funny_message},
|
||||
file={'name': 'auth_error.webp', 'alt': 'A monitor flashes unauthorized access in blinking red warning text'},
|
||||
), status_code
|
||||
|
||||
@app.errorhandler(401)
|
||||
async def handle_unauthorized(e):
|
||||
try:
|
||||
enforce_custom_limit(LIMITER, "401", limit_count=5, window_sec=1800)
|
||||
except LookupError as e:
|
||||
return await to_many_requests(e)
|
||||
add_wide_event_context(auth={"operation_status": "unauthorized"}, error={"type": type(e).__name__, "message": str(e)})
|
||||
context = get_request_context()
|
||||
if context.path.startswith("/api"):
|
||||
return jsonify({"error": "Unauthorized Access", "message": "Gandalf has spoken: You shall not pass… until you log in."}), 401
|
||||
|
||||
await logger.error(e)
|
||||
return redirect(url_for('auth_login.login'))
|
||||
await log_when_wide_event_disabled(logger, "error", e)
|
||||
return await render_template('views/basics/error.htm',
|
||||
title='Unauthorized Access',
|
||||
header={'title': '401 - Unauthorized', 'message': "Gandalf has spoken: You shall not pass… until you log in."},
|
||||
file={'name': '401.gif', 'alt': "Gandalf blocking the bridge – You shall not pass!"},
|
||||
), 401
|
||||
|
||||
@app.errorhandler(404)
|
||||
async def not_found(e):
|
||||
@@ -21,18 +144,70 @@ async def not_found(e):
|
||||
except LookupError as e:
|
||||
return await to_many_requests(e)
|
||||
|
||||
await logger.error(f"[404] Page Not Found: {request.path}")
|
||||
context = get_request_context()
|
||||
error_description = str(getattr(e, "description", ""))
|
||||
is_feature_flag_disabled_404 = error_description.startswith(FEATURE_FLAG_DISABLED_PREFIX)
|
||||
|
||||
if (
|
||||
not is_development_environment()
|
||||
and not is_feature_flag_disabled_404
|
||||
and context.path not in IGNORED_404_PATHS
|
||||
and not any(p in context.path for p in IGNORE_CONTAIN_404_PATHS)
|
||||
):
|
||||
await _safe_convex_error_tracking(
|
||||
"404_increment",
|
||||
current_app.convex.increment_page_not_found_error(path=context.path, status=404),
|
||||
)
|
||||
|
||||
add_wide_event_context(error={"type": "NotFound", "message": str(e)})
|
||||
await log_when_wide_event_disabled(logger, "error", f"[404] Page Not Found: {context.path}")
|
||||
|
||||
if context.path.startswith("/api"):
|
||||
return jsonify({"error": "Page Not Found", "message": "Oops! The page you are looking for does not exist."}), 404
|
||||
|
||||
return await render_template('views/basics/error.htm',
|
||||
title='Page Not Found',
|
||||
header={'title': '404 - Page Not Found', 'message': "Oops! The page you are looking for does not exist."},
|
||||
file={'name': '404.webp', 'alt': "Matrix - Neo stoping the Bullets by holding his hand up"},
|
||||
), 404
|
||||
|
||||
@app.errorhandler(418)
|
||||
async def maybe_a_hacker(e=None):
|
||||
add_wide_event_context(security={"blocked": True, "block_reason": "honeypot"})
|
||||
try:
|
||||
enforce_custom_limit(LIMITER, "BotScan", BLOCKED_IPS_ACCESSING_TIMES, BLOCKED_IPS_STORED_TIMEFRAME)
|
||||
except LookupError as e:
|
||||
client_ip=get_ip()
|
||||
await _safe_convex_error_tracking(
|
||||
"honeypot_ip_increment",
|
||||
current_app.convex.increment_blocked_ip_address_access(
|
||||
ip_address=client_ip,
|
||||
method=request.method,
|
||||
path=request.path,
|
||||
),
|
||||
)
|
||||
await log_when_wide_event_disabled(logger, "warning", f"[HONEYPOT] Blocked {client_ip} after accessing {request.path}")
|
||||
return await to_many_requests(e)
|
||||
|
||||
rendered = await render_template('views/basics/error.htm',
|
||||
title='Oops! Something Went AWOL!',
|
||||
header={'title': "418 - I'm a Teapot", 'message': f"You don't say the Magic Word. By the way, we might have your IP now, but don’t worry, it's in safe hands (probably). Feel free to keep poking around, just maybe give us a sec to catch our breath."},
|
||||
file={'name': 'hacker_crap.webp', 'alt': "Someone got Hacked and he says I hate this Hacker crap - Jurassic Park Movie"},
|
||||
)
|
||||
|
||||
response = await make_response((rendered, 418))
|
||||
response.headers['X-Honeypot-Triggered'] = 'true'
|
||||
response.headers['X-Reason'] = 'Unauthorized access attempt'
|
||||
|
||||
return response
|
||||
|
||||
@app.errorhandler(429)
|
||||
async def to_many_requests(e):
|
||||
add_wide_event_context(rate_limit={"limited": True}, error={"type": type(e).__name__, "message": str(e)})
|
||||
message = "We love your enthusiasm, but our server thought it was being DDoSed… by you. The keyboard needs a new set of keys and we need a nap. Try again soon!"
|
||||
|
||||
if request.path.startswith("/api") or request.path.endswith('/auth/userinfo') or request.path.endswith('/auth/refresh'):
|
||||
context = get_request_context()
|
||||
if context.path.startswith("/api") or context.path.endswith('/auth/userinfo') or context.path.endswith('/auth/refresh'):
|
||||
return jsonify({"error": "Too Many Requests - YOU SHALL NOT PASS (for now)", "message": message}), 429
|
||||
|
||||
return await render_template('views/basics/error.htm',
|
||||
@@ -41,30 +216,94 @@ async def to_many_requests(e):
|
||||
file={'name': '429_JimCarrey.gif', 'alt': "Jim Carrey Tips very fast on a computer keyboard"},
|
||||
), 429
|
||||
|
||||
@app.errorhandler(405)
|
||||
async def method_not_allowed(e):
|
||||
allowed_methods = getattr(e, "valid_methods", None) or getattr(e, "description", None)
|
||||
if not isinstance(allowed_methods, (list, tuple, set)):
|
||||
allowed_methods = getattr(e, "have_match_for", None) or []
|
||||
allowed_methods = sorted(str(method) for method in allowed_methods)
|
||||
allowed_methods_text = ", ".join(allowed_methods) if allowed_methods else "the supported method"
|
||||
message = f"Nice try, but {request.method} tried to enter this endpoint wearing fake glasses and a moustache. The bouncer only accepts {allowed_methods_text}."
|
||||
|
||||
add_wide_event_context(error={"type": type(e).__name__, "message": str(e)}, http={"allowed_methods": allowed_methods})
|
||||
await log_when_wide_event_disabled(logger, "warning", f"[405] Method Not Allowed: {request.method} {request.path}; allowed={allowed_methods_text}")
|
||||
|
||||
context = get_request_context()
|
||||
if context.path.startswith("/api"):
|
||||
response = await make_response(jsonify({"error": "Method Not Allowed", "message": message, "allowed_methods": allowed_methods}), 405)
|
||||
else:
|
||||
rendered = await render_template('views/basics/error.htm',
|
||||
title='Method Not Allowed',
|
||||
header={'title': '405 - Method Not Allowed', 'message': message},
|
||||
file={'name': '405_hal_9000_hal.gif', 'alt': "HAL 9000 calmly refuses with I'm Afraid i can't do that Dave"},
|
||||
)
|
||||
response = await make_response(rendered, 405)
|
||||
|
||||
if allowed_methods:
|
||||
response.headers['Allow'] = ", ".join(allowed_methods)
|
||||
|
||||
return response
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
async def handle_unexpected_exception(e):
|
||||
if isinstance(e, HTTPException):
|
||||
return e
|
||||
|
||||
add_httpx_error_wide_event_context(e)
|
||||
|
||||
if _is_convex_unavailable_error(e):
|
||||
status_code = _convex_unavailable_status_code(e)
|
||||
g.wide_event_handled_error_status = status_code
|
||||
add_wide_event_context(database={"provider": "convex", "available": False}, error={"type": type(e).__name__, "message": str(e)})
|
||||
await log_when_wide_event_disabled(logger, "error", f"[CONVEX_DOWN] Rendering database error response status={status_code} after Convex failure: {e}")
|
||||
return await database_server_error(e, status_code=status_code)
|
||||
|
||||
raise e
|
||||
|
||||
@app.errorhandler(500)
|
||||
async def internal_server_error(e):
|
||||
add_wide_event_context(**httpx_error_wide_event_context(e), error={"type": type(e).__name__, "message": str(e)})
|
||||
try:
|
||||
enforce_custom_limit(LIMITER, "500")
|
||||
except LookupError as e:
|
||||
return await to_many_requests(e)
|
||||
|
||||
await logger.error(e)
|
||||
context = get_request_context()
|
||||
if context.path.startswith("/api"):
|
||||
return jsonify({"error": "Internal Server Error", "message": "It looks like you broke something... but don't worry, we're fixing it! In the meantime, we may or may not have logged your IP address (just kidding... or are we?). Either way, thanks for helping us find new ways to crash our system. Stay curious, hacker-friend!"}), 500
|
||||
|
||||
await log_when_wide_event_disabled(logger, "error", e)
|
||||
return await render_template('views/basics/error.htm',
|
||||
title='Internal Server Error',
|
||||
header={'title': '500 - Internal Server Error', 'message': "It looks like you broke something... but don't worry, we're fixing it! In the meantime, we may or may not have logged your IP address (just kidding... or are we?). Either way, thanks for helping us find new ways to crash our system. Stay curious, hacker-friend!"},
|
||||
file={'name': '500.webp', 'alt': "Astronaut jumping and clicking on random Buttons as a red alert gone off - They is a Text on the Image saying: Why don't shit Work!?!"},
|
||||
), 500
|
||||
|
||||
@app.errorhandler(503)
|
||||
@app.errorhandler(504)
|
||||
async def database_server_error(e):
|
||||
async def database_server_error(e, status_code:int|None=None):
|
||||
status_code = status_code or getattr(e, "code", None) or 504
|
||||
add_wide_event_context(**httpx_error_wide_event_context(e), error={"type": type(e).__name__, "message": str(e)})
|
||||
try:
|
||||
enforce_custom_limit(LIMITER, "504")
|
||||
enforce_custom_limit(LIMITER, str(status_code))
|
||||
except LookupError as e:
|
||||
return await to_many_requests(e)
|
||||
|
||||
await logger.error(e)
|
||||
return await render_template('views/basics/error.htm',
|
||||
title='Database Error',
|
||||
header={'title': '504 - Database Error', 'message': "It looks like something is broke on our end... but don't worry, we're fixing it! Either way, thanks for helping us find new ways to crash our system. Stay curious, hacker-friend!"},
|
||||
file={'name': '504.gif', 'alt': "Hex Code running over a screen and ends with Error"},
|
||||
), 504
|
||||
retry_after = str(DATABASE_RETRY_AFTER_SECS)
|
||||
title = f'{status_code} - Database Error'
|
||||
message = "It looks like something is broke on our end... but don't worry, we're fixing it! Either way, thanks for helping us find new ways to crash our system. Stay curious, hacker-friend!"
|
||||
|
||||
await log_when_wide_event_disabled(logger, "error", e)
|
||||
context = get_request_context()
|
||||
if context.path.startswith("/api"):
|
||||
response = await make_response(jsonify({"error": "Database Error", "message": message}), status_code)
|
||||
else:
|
||||
rendered = await render_template('views/basics/error.htm',
|
||||
title='Database Error',
|
||||
header={'title': title, 'message': message},
|
||||
file={'name': '504.gif', 'alt': "Hex Code running over a screen and ends with Error"},
|
||||
)
|
||||
response = await make_response(rendered, status_code)
|
||||
|
||||
response.headers['Retry-After'] = retry_after
|
||||
return response
|
||||
|
||||
+104
-8
@@ -2,10 +2,34 @@ from my_modules.decoratory.header import login_required
|
||||
from my_modules.functions import get_ip
|
||||
from my_modules.app.setup import LIMITER
|
||||
from my_modules.app.logger import logger
|
||||
from my_modules.expiry import parse_expires
|
||||
from quart_common.web.wide_event import add_wide_event_context
|
||||
|
||||
from quart import Blueprint, request, session, Response, send_file, render_template, abort, current_app
|
||||
from quart import (
|
||||
Blueprint,
|
||||
request,
|
||||
session,
|
||||
Response,
|
||||
send_file,
|
||||
render_template,
|
||||
abort,
|
||||
current_app,
|
||||
jsonify,
|
||||
)
|
||||
|
||||
side_main_bp = Blueprint('side_main', __name__)
|
||||
side_main_bp = Blueprint("side_main", __name__)
|
||||
|
||||
def find_file(files: list[dict], file_id: str):
|
||||
for file_data in files:
|
||||
if file_data.get("file_id") == file_id:
|
||||
return file_data
|
||||
return None
|
||||
|
||||
def build_share_url(file_id: str) -> str:
|
||||
scheme = (request.headers.get("X-Forwarded-Proto") or request.scheme or "http").split(",")[0].strip()
|
||||
host = (request.headers.get("X-Forwarded-Host") or request.host).split(",")[0].strip()
|
||||
root_path = request.root_path.rstrip("/")
|
||||
return f"{scheme}://{host}{root_path}/-{file_id}"
|
||||
|
||||
@side_main_bp.route('/')
|
||||
@LIMITER.limit("10 per minute;50 per hour")
|
||||
@@ -17,34 +41,103 @@ async def index():
|
||||
@side_main_bp.route('/access')
|
||||
@login_required
|
||||
async def access_list(user):
|
||||
add_wide_event_context(nanoshare={"operation": "access_list"})
|
||||
access_data = await current_app.convex.get_all_access(user_id=user['sub'])
|
||||
return await render_template("views/webpage/access/list.htm", access_logs=access_data)
|
||||
|
||||
@side_main_bp.route('/files')
|
||||
@login_required
|
||||
async def files_list(user):
|
||||
add_wide_event_context(nanoshare={"operation": "files_list"})
|
||||
files_data = await current_app.convex.get_files(user_id=user['sub'])
|
||||
return await render_template("views/webpage/files/list.htm", files=files_data)
|
||||
return await render_template("views/webpage/files/list.htm",
|
||||
files=files_data
|
||||
)
|
||||
|
||||
@side_main_bp.route('/files/<path:file_id>/info')
|
||||
@login_required
|
||||
async def file_info(file_id, user):
|
||||
files_data = await current_app.convex.get_files(user_id=user['sub'])
|
||||
return await render_template("views/webpage/files/info.htm", files=files_data)
|
||||
add_wide_event_context(nanoshare={"operation": "file_info", "file_id": file_id})
|
||||
files_data = await current_app.convex.get_files(user_id=user["sub"])
|
||||
file_data = find_file(files_data, file_id)
|
||||
if not file_data:
|
||||
abort(404)
|
||||
|
||||
@side_main_bp.route('/files/<path:file_id>/edit')
|
||||
access_data = await current_app.convex.get_file_access(file_id=file_id, user_id=user["sub"]) or []
|
||||
share_url = build_share_url(file_id)
|
||||
return await render_template(
|
||||
"views/webpage/files/info.htm",
|
||||
file=file_data,
|
||||
accesses=access_data,
|
||||
share_url=share_url,
|
||||
)
|
||||
|
||||
@side_main_bp.route("/files/<path:file_id>/edit")
|
||||
@login_required
|
||||
async def file_edit(file_id, user):
|
||||
files_data = await current_app.convex.get_files(user_id=user['sub'])
|
||||
return await render_template("views/webpage/files/edit.htm", files=files_data)
|
||||
add_wide_event_context(nanoshare={"operation": "file_edit", "file_id": file_id})
|
||||
file_data = await current_app.convex.get_file_informations(file_id=file_id, user_id=user["sub"])
|
||||
if not file_data:
|
||||
abort(404)
|
||||
|
||||
share_url = build_share_url(file_id)
|
||||
return await render_template(
|
||||
"views/webpage/files/edit.htm", file=file_data, share_url=share_url, file_id=file_id
|
||||
)
|
||||
|
||||
@side_main_bp.put("/api/file/<path:file_id>")
|
||||
@login_required
|
||||
async def file_edit_api(file_id, user):
|
||||
add_wide_event_context(nanoshare={"operation": "file_update", "file_id": file_id})
|
||||
files_data = await current_app.convex.get_files(user_id=user["sub"])
|
||||
if not find_file(files_data, file_id):
|
||||
return jsonify({"ok": False, "error": "File not found"}), 404
|
||||
|
||||
payload = await request.get_json(silent=True)
|
||||
if payload is None:
|
||||
payload = await request.form
|
||||
|
||||
file_name = str(payload.get("file_name", "")).strip()
|
||||
note = str(payload.get("note", "")).strip()
|
||||
expires_raw = str(payload.get("expires", "")).strip()
|
||||
|
||||
if not file_name:
|
||||
return jsonify({"ok": False, "error": "Filename is required"}), 400
|
||||
|
||||
expires_at = parse_expires(expires_raw)
|
||||
if expires_raw and expires_raw != "never" and expires_at is None:
|
||||
return jsonify({"ok": False, "error": "Invalid expiration value"}), 400
|
||||
|
||||
await current_app.convex.update_file(
|
||||
file_id=file_id,
|
||||
file_name=file_name,
|
||||
note=note,
|
||||
expires_at=expires_at,
|
||||
user_id=user["sub"],
|
||||
)
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@side_main_bp.delete("/api/file/<path:file_id>")
|
||||
@login_required
|
||||
async def file_delete_api(file_id, user):
|
||||
add_wide_event_context(nanoshare={"operation": "file_delete", "file_id": file_id})
|
||||
files_data = await current_app.convex.get_files(user_id=user["sub"])
|
||||
if not find_file(files_data, file_id):
|
||||
return jsonify({"ok": False, "error": "File not found"}), 404
|
||||
|
||||
await current_app.convex.delete_file(file_id=file_id, user_id=user["sub"])
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@side_main_bp.route("/-<file_id>")
|
||||
@LIMITER.limit("10 per minute;500 per hour;")
|
||||
async def serve_file(file_id: str):
|
||||
add_wide_event_context(nanoshare={"operation": "serve_file", "file_id": file_id})
|
||||
file_data = await current_app.convex.get_file(file_id=file_id)
|
||||
disable_logging = False
|
||||
|
||||
if not file_data:
|
||||
add_wide_event_context(nanoshare={"operation_status": "not_found"})
|
||||
abort(404)
|
||||
|
||||
user = session.get('user')
|
||||
@@ -52,6 +145,7 @@ async def serve_file(file_id: str):
|
||||
disable_logging = True
|
||||
|
||||
if file_data.get("expired", None):
|
||||
add_wide_event_context(nanoshare={"operation_status": "expired", "owner_request": disable_logging})
|
||||
if not disable_logging:
|
||||
await current_app.convex.add_file_access(file_id=file_id, ip_address=get_ip(), user_agent=request.user_agent, status="expired")
|
||||
return Response("This file has expired.", status=410, headers={
|
||||
@@ -65,10 +159,12 @@ async def serve_file(file_id: str):
|
||||
force_download = request.args.get("download") in {"1", "true", "yes"}
|
||||
|
||||
if not file_data.get('db_image_url', None):
|
||||
add_wide_event_context(nanoshare={"operation_status": "missing_storage", "owner_request": disable_logging})
|
||||
if not disable_logging:
|
||||
await current_app.convex.add_file_access(file_id=file_id, ip_address=get_ip(), user_agent=request.user_agent, status="error")
|
||||
abort(404)
|
||||
|
||||
add_wide_event_context(nanoshare={"operation_status": "served", "owner_request": disable_logging, "content_type": content_type, "download": force_download})
|
||||
if not disable_logging:
|
||||
await current_app.convex.add_file_access(file_id=file_id, ip_address=get_ip(), user_agent=request.user_agent, status="ok")
|
||||
|
||||
|
||||
+139
-107
@@ -1,65 +1,20 @@
|
||||
from my_modules.decoratory.header import login_required
|
||||
from my_modules.expiry import parse_expires, ensure_utc
|
||||
from my_modules.file_meta import iso_stamp_filename, format_size
|
||||
from quart_common.web.wide_event import add_wide_event_context
|
||||
|
||||
from quart import Blueprint, request, jsonify, current_app
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import aiofiles, asyncio, re
|
||||
import asyncio, hashlib
|
||||
|
||||
upload_bp = Blueprint("upload_bp", __name__)
|
||||
upload_bp = Blueprint('upload_bp', __name__)
|
||||
|
||||
# --- Helpers -----------------------------------------------------
|
||||
|
||||
PRESET_H = re.compile(r"^(\d+)h$")
|
||||
PRESET_D = re.compile(r"^(\d+)d$")
|
||||
|
||||
def iso_stamp_filename(prefix: str, ext: str) -> str:
|
||||
"""Generate timestamped filename, e.g. pasted-2025-10-23T121212Z.png"""
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
ts = ts.replace(":", "").split(".")[0]
|
||||
if ts.endswith("+00:00"):
|
||||
ts = ts.replace("+00:00", "Z")
|
||||
return f"{prefix}-{ts}.{ext}"
|
||||
|
||||
def safe_name(name: str) -> str:
|
||||
"""Restrict filename to safe ASCII subset."""
|
||||
return re.sub(r"[^A-Za-z0-9._-]", "_", name)
|
||||
|
||||
def parse_expires(value: str | None) -> datetime | None:
|
||||
"""Parse expiration presets or ISO datetime."""
|
||||
if not value:
|
||||
return None
|
||||
value = value.strip()
|
||||
if m := PRESET_H.match(value):
|
||||
return datetime.now(timezone.utc) + timedelta(hours=int(m.group(1)))
|
||||
if m := PRESET_D.match(value):
|
||||
return datetime.now(timezone.utc) + timedelta(days=int(m.group(1)))
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def format_size(num_bytes: int) -> str:
|
||||
"""Return a human-readable file size (e.g., '2.3 MB', '10 Bytes')."""
|
||||
if num_bytes < 1024:
|
||||
return f"{num_bytes} Byte{'s' if num_bytes != 1 else ''}"
|
||||
|
||||
units = ["KB", "MB", "GB", "TB", "PB", "EB"]
|
||||
size = float(num_bytes)
|
||||
for unit in units:
|
||||
size /= 1024.0
|
||||
if size < 1024.0 or unit == units[-1]:
|
||||
# 1 decimal place; drop trailing .0 (optional)
|
||||
val = f"{size:.1f}"
|
||||
if val.endswith(".0"):
|
||||
val = val[:-2]
|
||||
return f"{val} {unit}"
|
||||
return f"{num_bytes} Bytes" # fallback
|
||||
|
||||
async def read_all(uploaded) -> bytes:
|
||||
"""Read all bytes from an uploaded file, handling sync or async .read()."""
|
||||
reader = getattr(uploaded, "read", None)
|
||||
reader = getattr(uploaded, 'read', None)
|
||||
if reader is None:
|
||||
return b""
|
||||
return b''
|
||||
if asyncio.iscoroutinefunction(reader):
|
||||
return await reader()
|
||||
|
||||
@@ -68,17 +23,41 @@ async def read_all(uploaded) -> bytes:
|
||||
return await data
|
||||
return data
|
||||
|
||||
def ensure_utc(dt:datetime):
|
||||
"""Ensure a timezone-aware UTC datetime or None."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
async def fingerprint_stream(stream, chunk_size:int=1024 * 1024) -> tuple[str|None, int|None]:
|
||||
if not hasattr(stream, 'seek') or not hasattr(stream, 'tell'):
|
||||
return None, None
|
||||
|
||||
try:
|
||||
stream.seek(0)
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
digest = hashlib.sha256()
|
||||
size_bytes = 0
|
||||
|
||||
while True:
|
||||
chunk = await asyncio.to_thread(stream.read, chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
size_bytes += len(chunk)
|
||||
digest.update(chunk)
|
||||
|
||||
try:
|
||||
stream.seek(0)
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
return digest.hexdigest(), size_bytes
|
||||
|
||||
|
||||
def fingerprint_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
# --- Routes ------------------------------------------------------
|
||||
|
||||
@upload_bp.post("/api/upload")
|
||||
@upload_bp.post('/api/upload')
|
||||
@login_required
|
||||
async def api_upload(user):
|
||||
"""
|
||||
@@ -90,76 +69,129 @@ async def api_upload(user):
|
||||
"""
|
||||
form = await request.form
|
||||
files = await request.files
|
||||
note = form.get("note", "")
|
||||
expires_raw = form.get("expires", "")
|
||||
text = form.get("text", "")
|
||||
note = form.get('note', '')
|
||||
expires_raw = form.get('expires', '')
|
||||
text = form.get('text', '')
|
||||
orphan_registry = getattr(current_app, 'orphan_storage_registry', None)
|
||||
|
||||
uploaded = files.get("file")
|
||||
add_wide_event_context(nanoshare={"operation": "upload", "has_file": bool(files.get('file')), "has_text": bool(text.strip())})
|
||||
|
||||
uploaded = files.get('file')
|
||||
expires_at_dt = ensure_utc(parse_expires(expires_raw))
|
||||
|
||||
if not uploaded and not text.strip():
|
||||
return jsonify({"ok": False, "error": "No content provided"}), 400
|
||||
add_wide_event_context(nanoshare={"operation_status": "missing_content"})
|
||||
return jsonify({'ok': False, 'error': 'No content provided'}), 400
|
||||
|
||||
content_type = None
|
||||
|
||||
# --- binary upload path ---
|
||||
if uploaded:
|
||||
fname = uploaded.filename or ""
|
||||
ctype = uploaded.mimetype or "application/octet-stream"
|
||||
fname = uploaded.filename or ''
|
||||
add_wide_event_context(nanoshare={"upload_type": "file", "filename_present": bool(fname)})
|
||||
ctype = uploaded.mimetype or 'application/octet-stream'
|
||||
content_type = ctype
|
||||
storage_id = None
|
||||
size_bytes = 0
|
||||
fingerprint = None
|
||||
reused_orphan_storage_id = False
|
||||
|
||||
# generate filename if missing/placeholder
|
||||
if not fname or fname.lower() in {"blob", "file"}:
|
||||
if not fname or fname.lower() in {'blob', 'file'}:
|
||||
ext = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"application/pdf": "pdf",
|
||||
"text/plain": "txt",
|
||||
}.get(ctype, "bin")
|
||||
fname = iso_stamp_filename("pasted", ext)
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'application/pdf': 'pdf',
|
||||
'text/plain': 'txt',
|
||||
}.get(ctype, 'bin')
|
||||
fname = iso_stamp_filename('pasted', ext)
|
||||
|
||||
fname = safe_name(fname)
|
||||
data = await read_all(uploaded)
|
||||
stream = getattr(uploaded, 'stream', None)
|
||||
|
||||
storage_id = await current_app.convex.send_to_storage(data=data, content_type=content_type)
|
||||
if stream is not None:
|
||||
fingerprint, detected_size = await fingerprint_stream(stream)
|
||||
size_bytes = detected_size or 0
|
||||
storage_id = (
|
||||
await orphan_registry.pop_recent(user['sub'], fingerprint)
|
||||
if orphan_registry
|
||||
else None
|
||||
)
|
||||
if storage_id:
|
||||
reused_orphan_storage_id = True
|
||||
add_wide_event_context(nanoshare={"reused_orphan_storage_id": True})
|
||||
else:
|
||||
storage_id, sent_size = await current_app.convex.send_stream_to_storage(stream=stream,content_type=content_type)
|
||||
size_bytes = sent_size
|
||||
else:
|
||||
data = await read_all(uploaded)
|
||||
fingerprint = fingerprint_bytes(data)
|
||||
storage_id = (
|
||||
await orphan_registry.pop_recent(user['sub'], fingerprint)
|
||||
if orphan_registry
|
||||
else None
|
||||
)
|
||||
if storage_id:
|
||||
reused_orphan_storage_id = True
|
||||
add_wide_event_context(nanoshare={"reused_orphan_storage_id": True})
|
||||
else:
|
||||
storage_id = await current_app.convex.send_to_storage(data=data, content_type=content_type)
|
||||
size_bytes = len(data)
|
||||
|
||||
size_bytes = len(data)
|
||||
file_size_pretty = format_size(size_bytes)
|
||||
|
||||
await current_app.convex.add_file(
|
||||
file_name=fname,
|
||||
file_size=file_size_pretty,
|
||||
note=note,
|
||||
content_type=content_type,
|
||||
expires_at=expires_at_dt,
|
||||
storage_id=storage_id,
|
||||
user_id=user['sub'],
|
||||
)
|
||||
try:
|
||||
await current_app.convex.add_file(
|
||||
file_name=fname,
|
||||
file_size=file_size_pretty,
|
||||
note=note,
|
||||
content_type=content_type,
|
||||
expires_at=expires_at_dt,
|
||||
storage_id=storage_id,
|
||||
user_id=user['sub'],
|
||||
)
|
||||
except Exception:
|
||||
if storage_id and not reused_orphan_storage_id and orphan_registry:
|
||||
await orphan_registry.remember(user['sub'], fingerprint, storage_id)
|
||||
raise
|
||||
|
||||
# --- text upload path ---
|
||||
elif text.strip():
|
||||
data = text.encode("utf-8")
|
||||
fname = iso_stamp_filename("pasted", "txt")
|
||||
path = current_app.upload_folder / fname
|
||||
|
||||
async with aiofiles.open(path, "wb") as f:
|
||||
await f.write(data)
|
||||
|
||||
storage_id = await current_app.convex.send_to_storage(data=data, content_type="text/plain")
|
||||
add_wide_event_context(nanoshare={"upload_type": "text"})
|
||||
data = text.encode('utf-8')
|
||||
fname = iso_stamp_filename('pasted', 'txt')
|
||||
fingerprint = fingerprint_bytes(data)
|
||||
storage_id = (
|
||||
await orphan_registry.pop_recent(user['sub'], fingerprint)
|
||||
if orphan_registry
|
||||
else None
|
||||
)
|
||||
reused_orphan_storage_id = bool(storage_id)
|
||||
if reused_orphan_storage_id:
|
||||
add_wide_event_context(nanoshare={"reused_orphan_storage_id": True})
|
||||
if not storage_id:
|
||||
storage_id = await current_app.convex.send_to_storage(
|
||||
data=data, content_type='text/plain'
|
||||
)
|
||||
|
||||
size_bytes = len(data)
|
||||
file_size_pretty = format_size(size_bytes)
|
||||
|
||||
await current_app.convex.add_file(
|
||||
file_name=fname,
|
||||
file_size=file_size_pretty,
|
||||
note=note,
|
||||
content_type="text/plain",
|
||||
expires_at=expires_at_dt,
|
||||
storage_id=storage_id,
|
||||
user_id=user['sub'],
|
||||
)
|
||||
try:
|
||||
await current_app.convex.add_file(
|
||||
file_name=fname,
|
||||
file_size=file_size_pretty,
|
||||
note=note,
|
||||
content_type='text/plain',
|
||||
expires_at=expires_at_dt,
|
||||
storage_id=storage_id,
|
||||
user_id=user['sub'],
|
||||
)
|
||||
except Exception:
|
||||
if not reused_orphan_storage_id and orphan_registry:
|
||||
await orphan_registry.remember(user['sub'], fingerprint, storage_id)
|
||||
raise
|
||||
|
||||
return jsonify({"ok": True})
|
||||
add_wide_event_context(nanoshare={"operation_status": "uploaded", "content_type": content_type})
|
||||
return jsonify({'ok': True})
|
||||
|
||||
@@ -10,15 +10,22 @@ import routes.handeling.errorsAndBots
|
||||
from routes import (
|
||||
basic_bp, auth_login_bp,
|
||||
side_main_bp,
|
||||
upload_bp
|
||||
upload_bp,
|
||||
health_bp
|
||||
)
|
||||
from routes.api.link import link_bp as servicelink_bp
|
||||
|
||||
# Views for Requests adding the uris
|
||||
app.register_blueprint(basic_bp)
|
||||
app.register_blueprint(auth_login_bp, url_prefix='/auth')
|
||||
app.register_blueprint(auth_login_bp)
|
||||
|
||||
app.register_blueprint(side_main_bp)
|
||||
app.register_blueprint(upload_bp)
|
||||
|
||||
# ServiceLink node-to-node mesh endpoint (POST /rpc)
|
||||
app.register_blueprint(servicelink_bp)
|
||||
|
||||
app.register_blueprint(health_bp, url_prefix='/health')
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=WEB_DEBUG, port=5502)
|
||||
|
||||
Submodule
+1
Submodule servicelink added at 7b9a51ee52
@@ -0,0 +1,210 @@
|
||||
{% extends "base.htm" %}
|
||||
|
||||
{% block title %}NanoShare - Edit file{% endblock %}
|
||||
|
||||
{% block meta %}
|
||||
<meta name="description" content="NanoShare file editing page.">
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.file-edit-page { margin: clamp(16px, 3vw, 32px) auto; }
|
||||
.info-line { margin-top: 12px; color: var(--muted); font-size: .95rem; }
|
||||
.save-row { margin-top: 18px; display: flex; justify-content: space-evenly; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.status-row { margin-top: 8px; text-align: center; }
|
||||
.status { color: var(--muted); font-size: .92rem; }
|
||||
.status.ok { color: #8ee28e; }
|
||||
.status.err { color: #f08f8f; }
|
||||
.btn-danger { border-color: #8f3b3b; color: #ffd4d4; }
|
||||
.btn-danger:hover { background: #4d1f1f; }
|
||||
.meta-table-wrap { margin-top: 10px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
.meta-table { width: 100%; border-collapse: collapse; }
|
||||
.meta-table th, .meta-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
|
||||
.meta-table tr:last-child th, .meta-table tr:last-child td { border-bottom: 0; }
|
||||
.meta-table th { width: 160px; color: var(--muted); font-weight: 700; }
|
||||
.danger-zone { margin-top: 20px; border: 1px solid #6a2c2c; border-radius: 12px; padding: 12px; background: rgba(120, 32, 32, 0.14); }
|
||||
.danger-zone h2 { margin: 0 0 6px; font-size: 1rem; }
|
||||
.danger-zone p { margin: 0 0 10px; color: var(--muted); font-size: .92rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="file-edit-page">
|
||||
<section class="card" aria-labelledby="edit-title">
|
||||
<h1 id="edit-title" class="page-title">Edit file</h1>
|
||||
<p class="subtle">Update filename, note and expiration date.</p>
|
||||
|
||||
<div class="meta-table-wrap" role="region" aria-label="File metadata">
|
||||
<table class="meta-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">URL</th>
|
||||
<td><a href="{{ share_url }}">{{ share_url }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">File ID</th>
|
||||
<td><code>{{ file_id }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Uploaded at</th>
|
||||
<td><time class="local-time" data-ts="{{ file.uploaded_at }}"></time></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">File size</th>
|
||||
<td><span class="badge">{{ file.file_size }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="fileName" class="label">Filename</label>
|
||||
<input id="fileName" class="input" type="text" maxlength="255" value="{{ file.file_name }}" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="note" class="label">Note</label>
|
||||
<input id="note" class="input" type="text" maxlength="500" value="{{ file.note or '' }}" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="expiresMode" class="label">Expiration</label>
|
||||
<select id="expiresMode" class="select">
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="24h">24 hours</option>
|
||||
<option value="7d">7 days</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="never">Never</option>
|
||||
<option value="custom">Custom date</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="customWrap" style="display:none;">
|
||||
<label for="customExpire" class="label">Custom expiration</label>
|
||||
<input type="datetime-local" id="customExpire" class="datetime" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-row">
|
||||
<button id="saveBtn" class="btn" type="button">Save changes</button>
|
||||
<a class="btn btn-ghost" href="{{ url_for('side_main.file_info', file_id=file_id) }}">View info</a>
|
||||
<a class="btn btn-ghost" href="{{ url_for('side_main.files_list') }}">Back to files</a>
|
||||
</div>
|
||||
<div class="status-row"><span id="status" class="status"></span></div>
|
||||
|
||||
<section class="danger-zone" aria-labelledby="danger-title">
|
||||
<h2 id="danger-title">Danger zone</h2>
|
||||
<p>Deleting a file is permanent and cannot be undone.</p>
|
||||
<button id="deleteBtn" class="btn btn-ghost btn-danger" type="button">Delete file</button>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const fileId = '{{ file_id }}';
|
||||
const initialExpires = '{{ file.expires_at }}';
|
||||
const expiresMode = document.getElementById('expiresMode');
|
||||
const customWrap = document.getElementById('customWrap');
|
||||
const customExpire = document.getElementById('customExpire');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Never';
|
||||
const date = new Date(Number.parseInt(String(value), 10));
|
||||
if (Number.isNaN(date.getTime())) return 'Invalid date';
|
||||
return date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function toLocalInputValue(timestampMs) {
|
||||
const d = new Date(Number.parseInt(String(timestampMs), 10));
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function setStatus(msg, type) {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = 'status';
|
||||
if (type) statusEl.classList.add(type);
|
||||
}
|
||||
|
||||
function syncExpireUI() {
|
||||
customWrap.style.display = expiresMode.value === 'custom' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
if (initialExpires) {
|
||||
expiresMode.value = 'custom';
|
||||
customExpire.value = toLocalInputValue(initialExpires);
|
||||
} else {
|
||||
expiresMode.value = 'never';
|
||||
}
|
||||
syncExpireUI();
|
||||
expiresMode.addEventListener('change', syncExpireUI);
|
||||
|
||||
document.querySelectorAll('time.local-time').forEach((el) => {
|
||||
el.textContent = formatDate(el.dataset.ts || '');
|
||||
});
|
||||
|
||||
document.getElementById('saveBtn').addEventListener('click', async () => {
|
||||
const fileName = document.getElementById('fileName').value.trim();
|
||||
const note = document.getElementById('note').value.trim();
|
||||
|
||||
if (!fileName) {
|
||||
setStatus('Filename is required.', 'err');
|
||||
return;
|
||||
}
|
||||
|
||||
let expires = expiresMode.value;
|
||||
if (expiresMode.value === 'custom') {
|
||||
if (!customExpire.value) {
|
||||
setStatus('Pick a custom date.', 'err');
|
||||
return;
|
||||
}
|
||||
expires = new Date(customExpire.value).toISOString();
|
||||
}
|
||||
|
||||
setStatus('Saving...');
|
||||
try {
|
||||
const response = await fetch(`/api/file/${encodeURIComponent(fileId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_name: fileName, note, expires }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) {
|
||||
throw new Error(data.error || 'Could not update file');
|
||||
}
|
||||
setStatus('Saved.', 'ok');
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Could not update file.', 'err');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('deleteBtn').addEventListener('click', async () => {
|
||||
const ok = window.confirm('Delete this file? This cannot be undone.');
|
||||
if (!ok) return;
|
||||
|
||||
setStatus('Deleting...');
|
||||
try {
|
||||
const response = await fetch(`/api/file/${encodeURIComponent(fileId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) {
|
||||
throw new Error(data.error || 'Could not delete file');
|
||||
}
|
||||
window.location.href = '{{ url_for("side_main.files_list") }}';
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Could not delete file.', 'err');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,210 @@
|
||||
{% extends "base.htm" %}
|
||||
|
||||
{% block title %}NanoShare - File info{% endblock %}
|
||||
|
||||
{% block meta %}
|
||||
<meta name="description" content="NanoShare file details and access information.">
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.file-info-page { padding: clamp(16px, 3vw, 28px); }
|
||||
.top-grid { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 16px; align-items: start; }
|
||||
.kv { display: grid; grid-template-columns: 180px 1fr; gap: 10px 14px; margin-top: 14px; }
|
||||
.kv dt { color: var(--muted); font-weight: 700; }
|
||||
.kv dd { margin: 0; word-break: break-word; }
|
||||
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: .92rem; }
|
||||
.preview-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: color-mix(in srgb, var(--panel-2) 60%, transparent); }
|
||||
.preview-head { padding: 10px 12px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: .9rem; font-weight: 700; }
|
||||
.preview-body { min-height: 230px; display: grid; place-items: center; padding: 10px; }
|
||||
.preview-body img, .preview-body video, .preview-body audio, .preview-body iframe { width: 100%; max-width: 100%; border: 0; border-radius: 8px; }
|
||||
.preview-body video { max-height: 260px; }
|
||||
.preview-body pre { width: 100%; max-height: 260px; overflow: auto; margin: 0; padding: 10px; border-radius: 8px; border: 1px solid var(--border); background: rgba(0,0,0,.16); white-space: pre-wrap; word-break: break-word; }
|
||||
.preview-note { color: var(--muted); font-size: .88rem; text-align: center; }
|
||||
@media (max-width: 980px) { .top-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 720px) { .kv { grid-template-columns: 1fr; gap: 4px; } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="file-info-page">
|
||||
<section class="card" style="padding: clamp(18px, 2.6vw, 28px);">
|
||||
<div class="top-grid">
|
||||
<div>
|
||||
<h1 class="page-title">File details</h1>
|
||||
<p class="subtle">Everything about this file, including expiration date and recent accesses.</p>
|
||||
|
||||
<dl class="kv">
|
||||
<dt>Filename</dt>
|
||||
<dd>{{ file.file_name }}</dd>
|
||||
|
||||
<dt>Note</dt>
|
||||
<dd>{{ file.note or 'No note' }}</dd>
|
||||
|
||||
<dt>Size</dt>
|
||||
<dd><span class="badge">{{ file.file_size }}</span></dd>
|
||||
|
||||
<dt>Uploaded at</dt>
|
||||
<dd><time class="local-time" data-ts="{{ file.uploaded_at }}"></time></dd>
|
||||
|
||||
<dt>Expires at</dt>
|
||||
<dd>
|
||||
{% if file.expires_at %}
|
||||
<time class="local-time" data-ts="{{ file.expires_at }}"></time>
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt>Public URL</dt>
|
||||
<dd><a class="mono" href="{{ share_url }}">{{ share_url }}</a></dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<aside class="preview-card" aria-label="File preview">
|
||||
<div class="preview-head">Preview</div>
|
||||
<div id="previewBody" class="preview-body">
|
||||
<p class="preview-note">Loading preview...</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<a class="btn" href="{{ url_for('side_main.file_edit', file_id=file.file_id) }}">Edit</a>
|
||||
<button id="copyUrlBtn" class="btn btn-ghost" type="button">Copy link</button>
|
||||
<a class="btn btn-ghost" href="{{ url_for('side_main.files_list') }}">Back to files</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" style="padding: clamp(18px, 2.6vw, 28px); margin-top: 16px;">
|
||||
<h2 style="margin:0 0 8px;">Access history</h2>
|
||||
<p class="subtle">Latest request metadata for this file.</p>
|
||||
|
||||
<div class="table-wrap" role="region" aria-label="Access history" tabindex="0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">IP</th>
|
||||
<th scope="col">User Agent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if accesses %}
|
||||
{% for access in accesses %}
|
||||
<tr>
|
||||
<td><time class="local-time" data-ts="{{ access.accessed_at }}"></time></td>
|
||||
<td><span class="badge">{{ access.status }}</span></td>
|
||||
<td>{{ access.ip or '-' }}</td>
|
||||
<td>{{ access.user_agent or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4">No access records yet.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function formatDate(value) {
|
||||
if (!value) return '';
|
||||
const raw = String(value);
|
||||
let date;
|
||||
if (/^\d+$/.test(raw)) {
|
||||
date = new Date(Number.parseInt(raw, 10));
|
||||
} else {
|
||||
date = new Date(raw);
|
||||
}
|
||||
if (Number.isNaN(date.getTime())) return raw;
|
||||
return date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('time.local-time').forEach((el) => {
|
||||
const raw = el.dataset.ts || '';
|
||||
el.textContent = formatDate(raw) || '-';
|
||||
});
|
||||
|
||||
async function loadPreview() {
|
||||
const previewBody = document.getElementById('previewBody');
|
||||
if (!previewBody) return;
|
||||
|
||||
let url = '{{ share_url }}';
|
||||
if (window.location.protocol === 'https:' && url.startsWith('http://')) {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.host === window.location.host) {
|
||||
parsed.protocol = 'https:';
|
||||
url = parsed.toString();
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url, { method: 'GET' });
|
||||
if (!response.ok) {
|
||||
previewBody.innerHTML = '<p class="preview-note">Preview unavailable.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||
|
||||
if (contentType.startsWith('image/')) {
|
||||
previewBody.innerHTML = `<img src="${url}" alt="File preview">`;
|
||||
return;
|
||||
}
|
||||
if (contentType.startsWith('video/')) {
|
||||
previewBody.innerHTML = `<video controls src="${url}"></video>`;
|
||||
return;
|
||||
}
|
||||
if (contentType.startsWith('audio/')) {
|
||||
previewBody.innerHTML = `<audio controls src="${url}"></audio>`;
|
||||
return;
|
||||
}
|
||||
if (contentType.includes('pdf')) {
|
||||
previewBody.innerHTML = `<iframe src="${url}" title="PDF preview" height="260"></iframe>`;
|
||||
return;
|
||||
}
|
||||
if (contentType.startsWith('text/') || contentType.includes('json') || contentType.includes('xml')) {
|
||||
const text = await response.text();
|
||||
const safe = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
previewBody.innerHTML = `<pre>${safe.slice(0, 6000) || 'Empty text file.'}</pre>`;
|
||||
return;
|
||||
}
|
||||
|
||||
previewBody.innerHTML = `<p class="preview-note">No inline preview for this file type.<br><a href="${url}" target="_blank" rel="noopener">Open file</a></p>`;
|
||||
} catch {
|
||||
previewBody.innerHTML = '<p class="preview-note">Preview unavailable.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
loadPreview();
|
||||
|
||||
document.getElementById('copyUrlBtn')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText('{{ share_url }}');
|
||||
const btn = document.getElementById('copyUrlBtn');
|
||||
if (!btn) return;
|
||||
const label = btn.textContent;
|
||||
btn.textContent = 'Copied';
|
||||
setTimeout(() => { btn.textContent = label; }, 1200);
|
||||
} catch {
|
||||
alert('Could not copy link.');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -176,20 +176,30 @@ fileInput.addEventListener('change',e=>{
|
||||
});
|
||||
|
||||
/* ====== Clipboard handling ====== */
|
||||
window.addEventListener('paste',e=>{
|
||||
if(file || (text && text.trim())) return;
|
||||
const items=e.clipboardData?.items||[];
|
||||
const fItem=[...items].find(it=>it.kind==='file');
|
||||
if(fItem){
|
||||
window.addEventListener('paste', e => {
|
||||
if (e.target === pasteEl) return; // 👈 let browser handle it
|
||||
|
||||
if (file || (text && text.trim())) return;
|
||||
|
||||
const items = e.clipboardData?.items || [];
|
||||
const fItem = [...items].find(it => it.kind === 'file');
|
||||
|
||||
if (fItem) {
|
||||
e.preventDefault();
|
||||
const blob=fItem.getAsFile();
|
||||
if(blob) setFileFromBlob(blob);
|
||||
const blob = fItem.getAsFile();
|
||||
if (blob) setFileFromBlob(blob);
|
||||
return;
|
||||
}
|
||||
const pasted=e.clipboardData?.getData('text')||'';
|
||||
if(pasted){ pasteEl.value=pasted; text=pasted; updateUI(); }
|
||||
|
||||
const pasted = e.clipboardData?.getData('text') || '';
|
||||
if (pasted) {
|
||||
pasteEl.value = pasted;
|
||||
text = pasted;
|
||||
updateUI();
|
||||
}
|
||||
});
|
||||
pasteEl.addEventListener('input',e=>{ text = e.target.value; updateUI(); });
|
||||
|
||||
pasteEl.addEventListener('input',e => { text = e.target.value; updateUI(); });
|
||||
|
||||
/* ====== Expiration handling ====== */
|
||||
expiresMode.addEventListener('change',()=>{
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: var(--primary-color);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
text-align: center;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: var(--secondary-color);
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #e74c3c;
|
||||
font-size: 48px;
|
||||
margin: 0;
|
||||
font-family: 'Press Start 2P', cursive;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 18px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.blocked-ip {
|
||||
font-weight: bold;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
:root {
|
||||
--primary-color: #f0f0f0;
|
||||
--secondary-color: white;
|
||||
|
||||
--nav-background-color: #333;
|
||||
|
||||
--text-color: #000;
|
||||
--text-muted: #555;
|
||||
|
||||
--table-head-background-color: #e1e1e1;
|
||||
|
||||
--container-width: 580px;
|
||||
--container-max-width: 903px;
|
||||
|
||||
--furry-button-color: #ffcaa6;
|
||||
--furry-button-color-hover: #ffa974;
|
||||
|
||||
--furry-background-color: white;
|
||||
--furry-decoration-color: #ff7f50;
|
||||
|
||||
--furry-list-element-color: #fff4e6;
|
||||
--furry-list-element-color-hover: #ffeede;
|
||||
|
||||
--story-text-color: #33ff33;
|
||||
--story-press-any-key-color: #ccc;
|
||||
--story-background-color: radial-gradient(circle at center, #222 0%, #000 100%);
|
||||
|
||||
--story-button-color: #0d0d0d;
|
||||
--story-button-hover-color: #003300;
|
||||
|
||||
--story-sidebar-background-color: black;
|
||||
--story-boot-logo-color: #ffaa00;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* --primary-color: #353535;
|
||||
--secondary-color: #1f1f23; */
|
||||
/* --primary-color: #2a2640;
|
||||
--secondary-color: #3a3458; */
|
||||
--primary-color: #1e1e2f;
|
||||
--secondary-color: #2a2a3d;
|
||||
|
||||
--nav-background-color: #272424;
|
||||
|
||||
--text-color: #cec9da;
|
||||
--text-muted: #999;
|
||||
|
||||
--table-head-background-color: var(--secondary-color);
|
||||
|
||||
--furry-button-color: #b67616;
|
||||
--furry-list-element-color: #252231;
|
||||
--furry-list-element-color-hover: var(--primary-color);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,31 @@
|
||||
User-agent: dotbot
|
||||
Disallow: /
|
||||
|
||||
User-agent: CCBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: barkrowler
|
||||
Disallow: /
|
||||
|
||||
User-agent: AhrefsBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: SemrushBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: serpstatbot
|
||||
Disallow: /
|
||||
|
||||
User-agent: InternetMeasurement
|
||||
Disallow: /
|
||||
|
||||
User-agent: CensysInspect
|
||||
Disallow: /
|
||||
|
||||
User-agent: MJ12bot
|
||||
Disallow: /
|
||||
|
||||
User-agent: *
|
||||
Crawl-delay: 3600
|
||||
Disallow: /.env
|
||||
Disallow: /api/v1
|
||||
@@ -0,0 +1,24 @@
|
||||
# acl.conf
|
||||
|
||||
# 1. Disable the default user (recommended for production)
|
||||
# This user is created by default with full access if no password is set.
|
||||
user default off
|
||||
|
||||
# 2. Define a "root" or "admin" user with full access
|
||||
user admin on >test +@all ~*
|
||||
|
||||
# 4. Define a "cache" user
|
||||
# This user can read and write to keys starting with "cache:".
|
||||
# This is great for application-specific keys.
|
||||
user cache on >your_strong_webapp_password +@all ~cache:*
|
||||
|
||||
# 5. Define a "session" user
|
||||
# This user can only get/set/del/expire keys related to caching.
|
||||
user session on >your_strong_cache_password +GET +SET +SETEX +DEL +EXPIRE ~session:*
|
||||
|
||||
# 6. Define a "cache" user
|
||||
# This user can read and write to keys starting with "cache:".
|
||||
# This is great for application-specific keys.
|
||||
user limiter on >your_strong_limiter_password +@all ~LIMITS:*
|
||||
|
||||
user pubsubuser on >strongpassword &printer:* +PUBLISH +SUBSCRIBE +PSUBSCRIBE +UNSUBSCRIBE +PUNSUBSCRIBE
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
valkey:
|
||||
image: valkey/valkey:latest
|
||||
ports:
|
||||
- 6379:6379
|
||||
volumes:
|
||||
- ./valkey_data:/data
|
||||
- ./acl.conf:/usr/local/etc/valkey/acl.conf
|
||||
command: valkey-server /usr/local/etc/valkey/acl.conf
|
||||
user: "${UID}"
|
||||
|
||||
redisinsight:
|
||||
image: redis/redisinsight:latest
|
||||
ports:
|
||||
- 5540:5540
|
||||
volumes:
|
||||
- ./redisinsight:/data
|
||||
user: "${UID}"
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
echo "UID=${UID}" > .env
|
||||
docker-compose up -d
|
||||
|
||||
cd ..
|
||||
WEB_DEBUG=true \
|
||||
./run.py
|
||||
@@ -0,0 +1,119 @@
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from quart import Blueprint, Quart, request
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
class FakeLimiter:
|
||||
def exempt(self, func):
|
||||
return func
|
||||
|
||||
def limit(self, *_args, **_kwargs):
|
||||
def decorator(func):
|
||||
return func
|
||||
return decorator
|
||||
|
||||
class FakeLogger:
|
||||
def __init__(self):
|
||||
self.errors = []
|
||||
|
||||
def error(self, message):
|
||||
self.errors.append(message)
|
||||
|
||||
def warning(self, message):
|
||||
pass
|
||||
|
||||
class FailingConvex:
|
||||
async def get_current_favicon(self):
|
||||
raise httpx.ConnectError("[Errno -2] Name or service not known")
|
||||
|
||||
def load_module(module_name, module_path):
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def install_route_test_modules(monkeypatch, app, logger):
|
||||
fake_setup = types.ModuleType("my_modules.app.setup")
|
||||
fake_setup.app = app
|
||||
fake_setup.LIMITER = FakeLimiter()
|
||||
monkeypatch.setitem(sys.modules, "my_modules.app.setup", fake_setup)
|
||||
|
||||
fake_constens = types.ModuleType("my_modules.app.constens")
|
||||
fake_constens.BLOCKED_IPS_ACCESSING_TIMES = 3
|
||||
fake_constens.BLOCKED_IPS_STORED_TIMEFRAME = 60
|
||||
monkeypatch.setitem(sys.modules, "my_modules.app.constens", fake_constens)
|
||||
|
||||
fake_logger_module = types.ModuleType("my_modules.app.logger")
|
||||
fake_logger_module.logger = logger
|
||||
monkeypatch.setitem(sys.modules, "my_modules.app.logger", fake_logger_module)
|
||||
|
||||
fake_functions = types.ModuleType("my_modules.functions")
|
||||
fake_functions.get_ip = lambda: "203.0.113.10"
|
||||
fake_functions.enforce_custom_limit = lambda *_args, **_kwargs: None
|
||||
fake_functions.get_request_context = lambda: types.SimpleNamespace(path=request.path)
|
||||
fake_functions.is_valid_uuid = lambda value: True
|
||||
monkeypatch.setitem(sys.modules, "my_modules.functions", fake_functions)
|
||||
|
||||
def register_template_routes(app):
|
||||
side_main = Blueprint("side_main", __name__)
|
||||
|
||||
async def index():
|
||||
return "ok"
|
||||
|
||||
side_main.add_url_rule("/", "index", index)
|
||||
side_main.add_url_rule("/files", "files_list", index)
|
||||
side_main.add_url_rule("/access", "access_list", index)
|
||||
app.register_blueprint(side_main)
|
||||
|
||||
auth_login = Blueprint("auth_login", __name__)
|
||||
auth_login.add_url_rule("/login", "login", index)
|
||||
auth_login.add_url_rule("/logout", "logout", index)
|
||||
app.register_blueprint(auth_login)
|
||||
|
||||
|
||||
def load_errors_and_basics(monkeypatch, app):
|
||||
logger = FakeLogger()
|
||||
install_route_test_modules(monkeypatch, app, logger)
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
errors = load_module("test_routes_handeling_errorsAndBots", root / "routes" / "handeling" / "errorsAndBots.py")
|
||||
basics = load_module("test_routes_handeling_basics", root / "routes" / "handeling" / "basics.py")
|
||||
return errors, basics, logger
|
||||
|
||||
def test_convex_connect_error_is_returned_as_global_database_error(monkeypatch):
|
||||
async def run_test():
|
||||
app = Quart(__name__, template_folder=str(Path(__file__).resolve().parents[1] / "templates" / "side"))
|
||||
register_template_routes(app)
|
||||
app.convex = FailingConvex()
|
||||
_errors, basics, logger = load_errors_and_basics(monkeypatch, app)
|
||||
app.register_blueprint(basics.basic_bp)
|
||||
|
||||
response = await app.test_client().get("/favicon.ico")
|
||||
|
||||
assert response.status_code == 504
|
||||
assert any("Name or service not known" in str(error) for error in logger.errors)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_api_convex_connect_error_returns_json_database_error(monkeypatch):
|
||||
async def run_test():
|
||||
app = Quart(__name__, template_folder=str(Path(__file__).resolve().parents[1] / "templates" / "side"))
|
||||
load_errors_and_basics(monkeypatch, app)
|
||||
|
||||
@app.get("/api/failing")
|
||||
async def failing_api():
|
||||
raise httpx.ConnectError("[Errno -2] Name or service not known")
|
||||
|
||||
response = await app.test_client().get("/api/failing")
|
||||
payload = await response.get_json()
|
||||
|
||||
assert response.status_code == 504
|
||||
assert payload["error"] == "Database Error"
|
||||
|
||||
asyncio.run(run_test())
|
||||
@@ -0,0 +1,106 @@
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from quart import Quart
|
||||
|
||||
from servicelink import Request, handle_envelope
|
||||
|
||||
# Load routes/api/link.py in isolation. Importing it as `routes.api.link` would
|
||||
# run routes/__init__.py -> my_modules.app.setup -> constens (asyncio.run at
|
||||
# import), which fails under pytest's output capture. We stub my_modules.app.setup
|
||||
# with a no-op limiter; link.py's other deps (my_modules.expiry/file_meta,
|
||||
# servicelink, quart) are side-effect free.
|
||||
class _NoOpLimiter:
|
||||
def limit(self, *args, **kwargs):
|
||||
return lambda fn: fn
|
||||
|
||||
_setup_stub = types.ModuleType('my_modules.app.setup')
|
||||
_setup_stub.LIMITER = _NoOpLimiter()
|
||||
sys.modules.setdefault('my_modules.app.setup', _setup_stub)
|
||||
|
||||
_LINK_PATH = Path(__file__).resolve().parents[1] / 'routes' / 'api' / 'link.py'
|
||||
_spec = importlib.util.spec_from_file_location('picoshare_link_under_test', _LINK_PATH)
|
||||
link = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(link)
|
||||
|
||||
class FakeConvex:
|
||||
def __init__(self):
|
||||
self.files = {}
|
||||
|
||||
async def decode_access_token_payload(self, access_token, required_scope=None, required_scopes=None):
|
||||
if access_token == 'good':
|
||||
return {'sub': 'user_123', 'scope': 'all'}
|
||||
if access_token == 'nomesh':
|
||||
return {'sub': 'user_123', 'scope': 'files'}
|
||||
return {'error': 'invalid token'}
|
||||
|
||||
async def send_to_storage(self, data, content_type):
|
||||
return 'storage_1'
|
||||
|
||||
async def add_file(self, file_name, file_size, note, content_type, expires_at, storage_id, user_id):
|
||||
self.files[file_name] = {
|
||||
'file_name': file_name,
|
||||
'note': note,
|
||||
'content_type': content_type,
|
||||
'storage_id': storage_id,
|
||||
'user_id': user_id,
|
||||
}
|
||||
return self.files[file_name]
|
||||
|
||||
async def get_file(self, file_id):
|
||||
return self.files.get(file_id)
|
||||
|
||||
def _call(envelope, token=None):
|
||||
# Call the handler the /rpc route delegates to, bypassing the LIMITER/size-cap
|
||||
# wrapper; exercises the real verify (mesh scope) + dispatch + handlers.
|
||||
async def run():
|
||||
app = Quart(__name__)
|
||||
app.convex = FakeConvex()
|
||||
async with app.test_request_context('/rpc', method='POST'):
|
||||
status, body, _ = await handle_envelope(
|
||||
link.router,
|
||||
json.dumps(envelope).encode('utf-8'),
|
||||
authorization=f'Bearer {token}' if token else None,
|
||||
verify=link.verify,
|
||||
content_type='application/json',
|
||||
)
|
||||
return status, json.loads(body)
|
||||
|
||||
return asyncio.run(run())
|
||||
|
||||
def _request(method, params=None):
|
||||
return Request.create(method, params or {}, source='browser-cli', target='picoshare').to_dict()
|
||||
|
||||
def test_text_upload_roundtrip():
|
||||
status, body = _call(_request('files.upload', {'text': 'hello mesh', 'file_name': 'note.txt', 'note': 'scraped'}), token='good')
|
||||
assert status == 200
|
||||
assert body['ok'] is True
|
||||
assert body['result']['file_name'] == 'note.txt'
|
||||
assert body['result']['content_type'] == 'text/plain'
|
||||
assert body['result']['size'] == len('hello mesh')
|
||||
|
||||
def test_upload_requires_content():
|
||||
status, body = _call(_request('files.upload', {'file_name': 'x'}), token='good')
|
||||
assert status == 422
|
||||
assert body['error']['code'] == 'invalid_params'
|
||||
|
||||
def test_get_missing_file_is_not_found():
|
||||
status, body = _call(_request('files.get', {'file_id': 'ghost'}), token='good')
|
||||
assert status == 404
|
||||
assert body['error']['code'] == 'not_found'
|
||||
|
||||
def test_missing_token_is_unauthorized():
|
||||
status, body = _call(_request('files.list'))
|
||||
assert status == 401
|
||||
assert body['error']['code'] == 'unauthorized'
|
||||
|
||||
def test_token_without_mesh_scope_is_forbidden():
|
||||
status, body = _call(_request('files.list'), token='nomesh')
|
||||
assert status == 403
|
||||
assert body['error']['code'] == 'forbidden'
|
||||
@@ -0,0 +1,148 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from quart import Quart, g, session
|
||||
|
||||
class FakeLogger:
|
||||
def __init__(self):
|
||||
self.records = []
|
||||
|
||||
async def info(self, message):
|
||||
self.records.append(("info", message))
|
||||
|
||||
async def warning(self, message):
|
||||
self.records.append(("warning", message))
|
||||
|
||||
async def error(self, message):
|
||||
self.records.append(("error", message))
|
||||
|
||||
class FakeIPBotManager:
|
||||
def is_client_ip_always_allowed(self, ip):
|
||||
return False
|
||||
|
||||
class FakeSetupApp:
|
||||
def before_request(self, func):
|
||||
return func
|
||||
|
||||
def context_processor(self, func):
|
||||
return func
|
||||
|
||||
class FakeConvex:
|
||||
async def is_ip_address_whitelisted_or_blocked(self, ip_address):
|
||||
return {"whiteliste": False, "blocked": False}
|
||||
|
||||
async def is_path_blocked(self, path):
|
||||
return False
|
||||
|
||||
class FailingConvex:
|
||||
async def is_ip_address_whitelisted_or_blocked(self, ip_address):
|
||||
raise RuntimeError("convex down")
|
||||
|
||||
def load_middleware(monkeypatch, client_ip="203.0.113.10", logger=None):
|
||||
fake_errors = types.ModuleType("routes.handeling.errorsAndBots")
|
||||
|
||||
async def maybe_a_hacker():
|
||||
return "blocked", 418
|
||||
|
||||
fake_errors.maybe_a_hacker = maybe_a_hacker
|
||||
|
||||
fake_constens = types.ModuleType("my_modules.app.constens")
|
||||
fake_constens.THE_IP_BOT_MANAGER = FakeIPBotManager()
|
||||
fake_constens.SKIP_PATH_PREFIXES = ("/static", "/storage")
|
||||
fake_constens.SKIP_PATHS = ("/favicon.ico",)
|
||||
|
||||
fake_logger_module = types.ModuleType("my_modules.app.logger")
|
||||
fake_logger_module.logger = logger or FakeLogger()
|
||||
|
||||
fake_functions = types.ModuleType("my_modules.functions")
|
||||
fake_functions.get_ip = lambda: client_ip
|
||||
|
||||
fake_setup = types.ModuleType("my_modules.app.setup")
|
||||
fake_setup.app = FakeSetupApp()
|
||||
|
||||
monkeypatch.setitem(sys.modules, "routes.handeling.errorsAndBots", fake_errors)
|
||||
monkeypatch.setitem(sys.modules, "my_modules.app.constens", fake_constens)
|
||||
monkeypatch.setitem(sys.modules, "my_modules.app.logger", fake_logger_module)
|
||||
monkeypatch.setitem(sys.modules, "my_modules.functions", fake_functions)
|
||||
monkeypatch.setitem(sys.modules, "my_modules.app.setup", fake_setup)
|
||||
sys.modules.pop("my_modules.middleware", None)
|
||||
|
||||
return importlib.import_module("my_modules.middleware")
|
||||
|
||||
def test_middleware_adds_user_and_client_context(monkeypatch):
|
||||
async def run_test():
|
||||
middleware = load_middleware(monkeypatch)
|
||||
monkeypatch.setattr(middleware, "get_ip", lambda: "203.0.113.10")
|
||||
monkeypatch.setattr(middleware, "logger", FakeLogger())
|
||||
|
||||
app = Quart(__name__)
|
||||
app.secret_key = "test-secret"
|
||||
app.convex = FakeConvex()
|
||||
|
||||
async with app.test_request_context("/files"):
|
||||
g.wide_event = {}
|
||||
session["user"] = {"sub": "user_123", "preferred_username": "demo"}
|
||||
session["login_at"] = 1
|
||||
|
||||
result = await middleware.custom_middleware()
|
||||
|
||||
assert result is None
|
||||
assert g.wide_event["user"]["id"] == "user_123"
|
||||
assert g.wide_event["user"]["name"] == "demo"
|
||||
assert g.wide_event["user"]["authenticated"] is True
|
||||
assert "authenticated" not in g.wide_event["session"]
|
||||
assert g.wide_event["session"]["permanent"] is True
|
||||
assert g.wide_event["session"]["login_at_unix"] == 1
|
||||
assert g.wide_event["session"]["age_seconds"] >= 0
|
||||
assert g.wide_event["client"]["ip"] == "203.0.113.10"
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_middleware_marks_missing_convex_as_skipped(monkeypatch):
|
||||
async def run_test():
|
||||
middleware = load_middleware(monkeypatch, client_ip="203.0.113.11")
|
||||
monkeypatch.setattr(middleware, "logger", FakeLogger())
|
||||
|
||||
app = Quart(__name__)
|
||||
app.secret_key = "test-secret"
|
||||
|
||||
async with app.test_request_context("/files"):
|
||||
g.wide_event = {}
|
||||
|
||||
result = await middleware.custom_middleware()
|
||||
|
||||
assert result is None
|
||||
assert g.wide_event["client"]["ip"] == "203.0.113.11"
|
||||
assert g.wide_event["security"] == {
|
||||
"convex_missing": True,
|
||||
"middleware_skipped": True,
|
||||
}
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_middleware_records_convex_security_failures(monkeypatch):
|
||||
async def run_test():
|
||||
fake_logger = FakeLogger()
|
||||
middleware = load_middleware(monkeypatch, client_ip="203.0.113.12", logger=fake_logger)
|
||||
|
||||
app = Quart(__name__)
|
||||
app.secret_key = "test-secret"
|
||||
app.convex = FailingConvex()
|
||||
|
||||
async with app.test_request_context("/files"):
|
||||
g.wide_event = {}
|
||||
|
||||
result = await middleware.custom_middleware()
|
||||
|
||||
assert result is None
|
||||
assert g.wide_event["client"]["ip"] == "203.0.113.12"
|
||||
assert g.wide_event["security"] == {"ip_lookup_failed": True}
|
||||
assert g.wide_event["error"]["type"] == "RuntimeError"
|
||||
assert fake_logger.records == [("error", "[MIDDLEWARE] Convex ip_lookup failed: convex down")]
|
||||
|
||||
asyncio.run(run_test())
|
||||
Reference in New Issue
Block a user