Compare commits

..

53 Commits

Author SHA1 Message Date
daniel156161 b45139f429 feat(mesh): support shared secret verification
- Allow the link API verifier to accept either mesh-scoped JWTs or a shared secret.
- Read SERVICELINK_MESH_SECRET from the environment for trusted Docker network calls.
- Update the servicelink submodule to include shared-secret verifier support.
2026-06-14 12:21:04 +02:00
daniel156161 60139f48f2 fix: harden storage proxy and error handling
Build and Push Docker Container / build-and-push (push) Successful in 2m3s
- Preserve Convex storage response headers while validating allowed component query parameters.
- Add fallback favicon routes for common browser and resource paths.
- Make Convex error tracking non-blocking so error pages still render when tracking fails.
- Return clearer 405 responses and map Convex outages to 503 or 504 with Retry-After headers.
- Route logging through wide-event-aware helpers to avoid duplicate logs when structured events are enabled.
2026-06-14 01:07:56 +02:00
daniel156161 ca85dc2265 refactor: use shared ServiceLink blueprint
- Replace the local /rpc Quart route with the shared ServiceLink blueprint helper.
- Move mesh scope enforcement into bearer_verifier via require_scope.
- Update ServiceLink tests to call the public handle_envelope and verify exports.
- Advance the servicelink submodule to include the shared CLI and RPC discovery work.
2026-06-14 00:32:27 +02:00
daniel156161 1dbf3b36ff chore(deps): refresh uv lock dependencies
Build and Push Docker Container / build-and-push (push) Successful in 1m17s
- Update the uv lockfile with the latest resolved dependency versions.
- Refresh security/auth-adjacent packages including cryptography, certifi, idna, and joserfc.
- Pull in current compatibility fixes for aiohappyeyeballs, yarl, werkzeug, wrapt, packaging, click, and propcache.
2026-06-14 00:14:36 +02:00
daniel156161 2982d44e55 feat: add servicelink RPC mesh endpoint
Build and Push Docker Container / build-and-push (push) Successful in 3m21s
- Add the servicelink submodule and register POST /rpc for node-to-node file operations.
- Require bearer tokens with the mesh scope and apply rate/body-size limits to RPC calls.
- Map database connectivity failures to the existing 504 database error flow, with JSON responses for API routes.
- Cover the new RPC handlers and database error handling with focused pytest tests.
- Bump the NanoShare package version to 1.21.0.
2026-06-13 23:59:35 +02:00
daniel156161 cb6422aacb chore: release NanoShare 1.20.0
Build and Push Docker Container / build-and-push (push) Successful in 2m4s
- Bump the application version from 0.1.0 to 1.20.0 using the project version scheme.

- Trim pyproject dependencies to direct root packages only and let uv.lock keep the resolved transitive set.

- Update the quart_common submodule to include the latest wide-event session context handling.

- Adjust NanoShare wide-event middleware expectations for the new session payload shape.
2026-05-13 22:01:06 +02:00
daniel156161 a73c0302a4 add joserfc package for new auth
Build and Push Docker Container / build-and-push (push) Successful in 1m14s
2026-05-13 21:01:30 +02:00
daniel156161 4cefc4e0ad update uv lock file to correct nameing
Build and Push Docker Container / build-and-push (push) Successful in 1m27s
2026-05-13 20:46:58 +02:00
daniel156161 5b619670ee update quart common with middleware pre_database_hooks
Build and Push Docker Container / build-and-push (push) Failing after 45s
2026-05-13 20:40:17 +02:00
daniel156161 9c731d6e67 feat(logging): add NanoShare wide event instrumentation
Build and Push Docker Container / build-and-push (push) Failing after 51s
- Register quart_common wide-event logging during app setup so every HTTP request emits one canonical structured event.

- Replace the inline security middleware with reusable quart_common security middleware wiring and move skip path configuration into app constants.

- Add NanoShare-specific wide-event context for health checks, auth/error handlers, file list/edit/delete/serve flows and upload outcomes.

- Rename runtime logging/project metadata from simple-picoshare to nanoshare where it is emitted in service context.

- Update my_helpers and quart_common submodules for Convex/wide-event integration and reusable security middleware support.

- Add NanoShare middleware tests covering safe user context, client IP enrichment, missing Convex handling and Convex security lookup failures.
2026-05-13 20:22:43 +02:00
Daniel Dolezal 26536a3cde fix: rewrite SSH submodule URLs to HTTPS in CI
Build and Push Docker Container / build-and-push (push) Successful in 2m7s
2026-05-02 09:39:13 +02:00
daniel156161 42ce022d7b use new shared blueprint function
Build and Push Docker Container / build-and-push (push) Successful in 1m50s
2026-04-15 19:00:16 +02:00
daniel156161 6ead4b8541 update my_helpers submodule
Build and Push Docker Container / build-and-push (push) Successful in 1m29s
2026-04-15 18:49:58 +02:00
daniel156161 0b81d3d803 use login from quard_common 2026-04-15 18:49:17 +02:00
daniel156161 3d8d74785c fix file preview url
Build and Push Docker Container / build-and-push (push) Successful in 1m30s
2026-04-11 11:35:39 +02:00
daniel156161 1c58de68b6 update submodules 2026-04-11 11:35:20 +02:00
daniel156161 c28e4874d9 update my_helpers and remove useless newline into logger
Build and Push Docker Container / build-and-push (push) Successful in 1m23s
2026-04-06 14:25:48 +02:00
daniel156161 680e8dafff allow to strem upload to convex and reuse file id when upload error happend 2026-04-06 14:20:11 +02:00
daniel156161 b170fcfa98 use hypercorn from .venv folder 2026-04-01 22:11:17 +02:00
daniel156161 4773338ccc add convex health metrics route
Build and Push Docker Container / build-and-push (push) Successful in 2m4s
2026-04-01 21:32:28 +02:00
daniel156161 d9b7c88ccf fix login that it not shows 500 when authentik is to slow
Build and Push Docker Container / build-and-push (push) Successful in 1m51s
2026-04-01 21:17:01 +02:00
daniel156161 7b77387182 remove feature_flag_required for edit and info routes and buttons 2026-04-01 21:15:23 +02:00
daniel156161 65951a23ce use correct convex function and change how the edit html gets the file id 2026-04-01 21:05:07 +02:00
daniel156161 5bbc100d83 add info page for files 2026-04-01 20:39:25 +02:00
daniel156161 cf489c9f4a update submodules
Build and Push Docker Container / build-and-push (push) Failing after 11m40s
2026-04-01 19:45:39 +02:00
daniel156161 eeda177182 allow to edit files 2026-04-01 19:45:22 +02:00
daniel156161 0681bd398c not show pages that not exists yet
Build and Push Docker Container / build-and-push (push) Successful in 1m25s
2026-04-01 16:19:56 +02:00
daniel156161 ea4738ad06 use new quart_common functions 2026-04-01 16:17:30 +02:00
daniel156161 51f02ff5c8 add quart common submodule 2026-04-01 14:38:49 +02:00
daniel156161 b311bcae11 updateing my_helpers submodule 2026-04-01 13:44:55 +02:00
daniel156161 063ff3ca58 change convex from runtime to worker pool
Build and Push Docker Container / build-and-push (push) Successful in 1m52s
2026-03-28 13:21:09 +01:00
daniel156161 fc3a0219a0 update requirements 2026-03-28 13:15:40 +01:00
daniel156161 a6befb5aeb update actions to new version:
Build and Push Docker Container / build-and-push (push) Successful in 1m21s
- checkout: v6
- setup-buildx-action: v4
- login-action: v4
- build-push-action: v7
2026-03-10 08:59:17 +01:00
daniel156161 cc79e43b3c add /login and /logout paths to user auth
Build and Push Docker Container / build-and-push (push) Successful in 1m4s
2026-02-26 11:17:16 +01:00
daniel156161 367e9fbdb6 update requirements
Build and Push Docker Container / build-and-push (push) Successful in 1m17s
2026-02-26 10:39:40 +01:00
daniel156161 6137121209 update to get favicon from compontent by using the storage
Build and Push Docker Container / build-and-push (push) Successful in 1m36s
2026-02-16 12:02:52 +01:00
daniel156161 e8e37e8967 update requirements
Build and Push Docker Container / build-and-push (push) Successful in 1m18s
2026-02-11 12:44:20 +01:00
daniel156161 3cd30d3bad update requirements
Build and Push Docker Container / build-and-push (push) Successful in 1m42s
2026-02-09 10:09:06 +01:00
daniel156161 bea2d98fa6 update requirements
Build and Push Docker Container / build-and-push (push) Successful in 2m9s
2026-01-22 10:19:13 +01:00
daniel156161 d82ed3d43a fix adding of pasted text into convex and fix double paste problem
Build and Push Docker Container / build-and-push (push) Successful in 1m40s
2026-01-19 18:52:27 +01:00
daniel156161 e541000347 create Variables for to many 418 errors when they are a hacker or bot to get blocked
Build and Push Docker Container / build-and-push (push) Successful in 2m28s
2026-01-07 00:00:44 +01:00
daniel156161 b7ec488b44 add check if accessed storage file_id is a uuid and when not send them to a 404 error page
Build and Push Docker Container / build-and-push (push) Successful in 1m31s
2026-01-01 10:29:45 +01:00
daniel156161 9b69069367 update my_helpers submodule 2026-01-01 10:26:02 +01:00
daniel156161 6930bb3a61 remove not neaded safe_name and remove import of pathlib
Build and Push Docker Container / build-and-push (push) Successful in 1m20s
2025-12-29 09:17:42 +01:00
daniel156161 715af77a8c add code to increment blocked path access
Build and Push Docker Container / build-and-push (push) Successful in 1m21s
2025-12-29 09:12:52 +01:00
daniel156161 6280299770 add robots txt file
Build and Push Docker Container / build-and-push (push) Successful in 1m22s
2025-12-23 15:24:22 +01:00
daniel156161 a39862b5ab add testing files 2025-12-23 15:21:23 +01:00
daniel156161 ca7fbe9b80 update my_helpers and git ignore 2025-12-23 15:21:00 +01:00
daniel156161 729e7f5fca add protection that shares the data with my webside 2025-12-23 15:20:36 +01:00
daniel156161 0b2d635fd8 remove gel db completly with package
Build and Push Docker Container / build-and-push (push) Successful in 1m58s
2025-12-22 14:11:19 +01:00
daniel156161 e4171e4b73 delete gel db database schema 2025-12-22 14:03:53 +01:00
daniel156161 410e09a980 add working accessed quary and rename fields in html template
Build and Push Docker Container / build-and-push (push) Successful in 1m37s
2025-12-22 14:00:02 +01:00
daniel156161 fefda61c0b use new convex db base class and remove code that is already into the base class
Build and Push Docker Container / build-and-push (push) Successful in 1m21s
2025-12-22 11:43:05 +01:00
49 changed files with 2512 additions and 1748 deletions
+8 -4
View File
@@ -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
View File
@@ -1,8 +1,11 @@
__pycache__/
uploads/
.venv/
__pycache__/
*.pyc
*.json
*.edgeql
.env
access.log
valkey_data/
redisinsight/
+6
View File
@@ -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
View File
@@ -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", "-"]
-62
View File
@@ -1,62 +0,0 @@
using future simple_scoping;
module default {
scalar type access_status extending enum<ok, denied, expired, error>;
type IPAddr {
required value: str {
constraint exclusive;
}
}
type UserAgent {
required value: str {
constraint exclusive;
}
}
type files {
required file_id: str {
constraint exclusive;
};
required file_name: str;
required file_size: str;
required note: str;
required content_type: str {
readonly := true;
}
property expires_at: datetime;
required uploaded_at: datetime {
readonly := true;
}
multi accesses -> file_access {
on source delete delete target if orphan;
};
required property user_id: str {
readonly := true;
};
index on ((.file_id, .file_name, .expires_at, .user_id));
}
type file_access {
required link ip -> IPAddr {
readonly := true;
on source delete delete target if orphan;
}
required link user_agent -> UserAgent {
readonly := true;
on source delete delete target if orphan;
}
required status: access_status {
default := access_status.ok;
}
required at: datetime {
readonly := true;
default := datetime_of_statement();
}
}
}
-4
View File
@@ -1,4 +0,0 @@
watch = []
[instance]
server-version = "6.11"
+67
View File
@@ -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()
+14
View File
@@ -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)
+9 -3
View File
@@ -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))
+2 -11
View File
@@ -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
View File
@@ -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"), service='nanoshare')
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()
+46 -325
View File
@@ -1,105 +1,30 @@
from concurrent.futures import ThreadPoolExecutor
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 ConvexClient, ConvexError, ConvexExecutionError
from urllib.parse import urlparse
from datetime import datetime
from quart import url_for
from urllib.parse import urlparse, urlunparse
from io import BytesIO
import aiohttp, httpx, asyncio
class ConvexDB:
default_namespace = 'nanoshare'
service_protection = 'service/protection'
service_auth = 'service/auth'
class ConvexDB(ConvexDbBase):
service_namespace = 'nanoshare'
def __init__(self, dsn:str, service:str):
self.locked_printer = {}
self.client = None
self.dsn = dsn
self.service = service
self.executor = ThreadPoolExecutor(max_workers=1)
self.loop = asyncio.get_event_loop()
async def replace_url_host(self, url:str) -> str:
dsn = urlparse(self.dsn.rstrip('/'))
keep_content = urlparse(url)
return urlunparse((
dsn.scheme,
dsn.netloc,
keep_content.path,
keep_content.params,
keep_content.query,
keep_content.fragment,
))
# Connect Function
async def connect(self):
self.client = ConvexClient(self.dsn)
async def close(self):
if self.client:
self.client = None
def subscribe(self, name:str, args:dict):
return self.client.subscribe(name=f'{self.default_namespace}/{name}', args=args)
# Query Helper Function
async def run_query_with_reconnection(self, function, *args:tuple, **kwargs):
while True:
try:
query = args[0].replace('\n ', '').replace(' ', '')
if args[1:]:
await logger.debug(f'{function.__name__} |{query}| {args[1:]}{kwargs}')
else:
await logger.debug(f'{function.__name__} |{query}| {kwargs}')
return await self.loop.run_in_executor(
self.executor, lambda: function(*args, **kwargs)
def __init__(self, runtime:ConvexRuntime):
super().__init__(
runtime=runtime,
service=ConvexDB.service_namespace
)
except Exception as e:
await logger.error(f'Query Database: {e}, Function: {args}')
break
async def get_correct_storage_api_url(self, obj:dict, key:str):
update_value = obj[key]
if update_value:
obj[key] = url_for('basic.convex_storage_proxy', file_id=urlparse(update_value).path.rsplit('/', 1)[-1])
return obj
# Basic Blueprint Function
async def get_current_favicon(self):
data = await self.run_query_with_reconnection(
self.client.query,
f'{self.default_namespace}/cache/favicon:get',
args={}
)
return data
async def set_new_favicon(self, favicon:str):
data = await self.run_query_with_reconnection(
self.client.mutation,
f'{self.default_namespace}/cache/favicon:set',
args={ 'favicon': favicon }
)
return data
# File Quary Functions
async def get_file(self, file_id:str):
data = await self.run_query_with_reconnection(
self.client.query,
f"{self.default_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.default_namespace}/files:getAllNotExpired",
data = await self.run_query(
name='files:getAllNotExpired',
args={ 'user_id': user_id }
)
return [ {
@@ -111,7 +36,7 @@ class ConvexDB:
"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,
@@ -120,74 +45,58 @@ class ConvexDB:
if expires_at:
args['expires_at'] = expires_at.isoformat()
data = await self.run_query_with_reconnection(
self.client.mutation,
f"{self.default_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.default_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.default_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.default_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,
"""
select files {
file_id,
file_name,
note,
accesses: {
at,
status,
ip: {
value
},
user_agent: {
value
}
}
order by .at desc
}
filter .user_id = <str>$user_id
""",
user_id=user_id
data = await self.run_query(
name='access:getAllByUser',
args={ 'user_id': user_id }
)
return sorted([{
"file_id": file.file_id,
"file_name": file.file_name,
"file_note": file.note,
"status": access.status,
"ip": access.ip.value,
"user_agent": access.user_agent.value,
"accessed_at": access.at,
} for file in data for access in file.accesses], key=lambda x: x["accessed_at"], reverse=True)
return data
async def get_file_access(self, file_id:str, user_id:str):
return []
async def get_file_access(self, file_id: str):
data = await self.run_query_with_reconnection(
self.client.query_single,
"""
@@ -213,191 +122,3 @@ class ConvexDB:
"accessed_at": access.at,
} for access in data.accesses]
return None
# Stream Data from DB File Storage
async def stream_from_storage(self, convex_upload_url:str):
convex_upload_url = await self.replace_url_host(convex_upload_url)
async with aiohttp.ClientSession() as session:
async with session.get(convex_upload_url) as resp:
resp.raise_for_status()
async for chunk in resp.content.iter_chunked(4096):
yield chunk
async def get_from_storage(self, convex_upload_url:str):
convex_upload_url = await self.replace_url_host(convex_upload_url)
async with httpx.AsyncClient() as client:
resp = await client.get(convex_upload_url)
resp.raise_for_status()
return BytesIO(resp.read())
async def send_to_storage(self, data:bytes, content_type:str):
convex_upload_url = await self.run_query_with_reconnection(
self.client.mutation,
f"service/files:prepareUpload",
args={}
)
convex_upload_url = await self.replace_url_host(convex_upload_url)
async with httpx.AsyncClient() as client:
resp = await client.post(convex_upload_url,
headers={
"Content-Type": content_type,
"Content-Length": str(len(data)),
},
content=data,
)
resp.raise_for_status()
return resp.json()["storageId"]
# User
async def upsert_user(self, user:dict):
data = await self.run_query_with_reconnection(
self.client.mutation,
f'{self.service_auth}/users:upsert',
args={
'sub': user['sub'],
'email': user['email'],
'name': user['name'],
'username': user['preferred_username'],
'groups': user['groups'],
}
)
return data
async def login(self, code:str):
"""
NOT WORKING - AUTH Still Beta in Convex -> Currently do Manual
"""
data = self.client.action('auth:signIn', args={'provider': 'authentik', 'params': {'code': code}})
print(data)
# Blocked Paths Functions
async def set_blocked_paths(self, path:str, subpath:str):
data = await self.run_query_with_reconnection(
self.client.mutation,
f"{self.service_protection}/blockedPaths:create",
args={ 'path': path, 'subpath': subpath }
)
return data
async def get_blocked_paths(self) -> set[str]:
data = await self.run_query_with_reconnection(
self.client.query,
f"{self.service_protection}/blockedPaths:getAll",
args={}
)
return data
async def is_path_blocked(self, path:str) -> bool:
data = await self.run_query_with_reconnection(
self.client.query,
f"{self.service_protection}/blockedPaths:isBlocked",
args={ 'path': path }
)
return data
# Blocked IP Address Functions
async def set_ip_address_to_blocklist(self, ip_address:str, method:str, path:str, blocked:bool, accessed:int, last_access_at:datetime):
data = await self.run_query_with_reconnection(
self.client.mutation,
f"{self.service_protection}/blockedIpAdresses:create",
args={ 'ip': ip_address, 'method': method, 'path': path, 'blocked': blocked, 'accessed': accessed, 'last_access_at': last_access_at.isoformat() }
)
return data
async def increment_blocked_ip_address_access(self, ip_address:str, method:str, path:str):
data = await self.run_query_with_reconnection(
self.client.mutation,
f"{self.service_protection}/blockedIpAdresses:increment",
args={ 'ip': ip_address, 'method': method, 'path': path }
)
if data == 1:
await logger.info(f'Add New IP Address to Block List {ip_address}, {method}, {path}')
async def is_ip_address_whitelisted_or_blocked(self, ip_address:str) -> bool:
data = await self.run_query_with_reconnection(
self.client.query,
f"{self.service_protection}/blockedIpAdresses:isWhitelistedOrBlocked",
args={ 'ip': ip_address }
)
return data
# Token Functions
async def get_tokens(self, user_id:str):
data = await self.run_query_with_reconnection(
self.client.query,
f"{self.service_auth}/jwt:getRefreshTokenByUserAndService",
args={ 'service': self.service, 'user_id': user_id }
)
return data
async def add_refresh_token(self, token_name:str, user_id:str):
data = await self.run_query_with_reconnection(
self.client.mutation,
f"{self.service_auth}/jwt:createRefreshToken",
args={ 'token_name': token_name, 'service': self.service, 'user_id': user_id }
)
return data
async def update_refresh_token_name(self, token_name:str, token_id:str, user_id:str):
data = await self.run_query_with_reconnection(
self.client.mutation,
f"{self.service_auth}/jwt:updateRefreshToken",
args={ 'token_id': token_id, 'token_name': token_name, 'service': self.service, 'user_id': user_id }
)
return data
async def remove_refresh_token(self, token_id:str, user_id:str):
data = await self.run_query_with_reconnection(
self.client.mutation,
f"{self.service_auth}/jwt:deleteRefreshToken",
args={ 'token_id': token_id, 'service': self.service, 'user_id': user_id }
)
return data
async def generate_new_access_token(self, refresh_token:str):
data = await self.run_query_with_reconnection(
self.client.action,
f"{self.service_auth}/jwt:exchangeRefreshForAccess",
args={ 'refresh_token': refresh_token, 'service': self.service }
)
if data.get('new_token', None):
return {
"access_token": data['new_token']['access_token'],
"token_type": data['new_token']['token_type'],
"expires_in": int(data['new_token']['expires_in']),
"created_at": int(data['new_token']['created_at']) / 1000,
"expires_at": int(data['new_token']['expires_at']) / 1000,
}, data['refresh_id']
return data, None
async def decode_access_token_payload(self, access_token:str):
data = await self.run_query_with_reconnection(
self.client.action,
f"{self.service_auth}/jwt:decodeAccessToken",
args={ 'access_token': access_token, 'service': self.service }
)
if data.get('payload', None):
return data.get('payload')
return data
# Error Page Access
async def set_page_not_found_error(self, path:str, status:int, accessed:int, last_access_at:datetime):
data = await self.run_query_with_reconnection(
self.client.mutation,
f"{self.service_protection}/badPageAccess:create",
args={ 'path': path, 'status': status, 'accessed': accessed, 'last_access_at': last_access_at.isoformat() }
)
return data
async def increment_page_not_found_error(self, path:str, status:int):
await self.run_query_with_reconnection(
self.client.mutation,
f"{self.service_protection}/badPageAccess:increment",
args={ 'path': path, 'status': status }
)
+22 -145
View File
@@ -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,
)
+32
View File
@@ -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)
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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 -53
View File
@@ -1,67 +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",
"gel==3.1.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
View File
@@ -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
View File
@@ -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
+31
View File
@@ -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
+119
View File
@@ -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)
-139
View File
@@ -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
+35 -29
View File
@@ -1,40 +1,46 @@
from my_modules.app.setup import LIMITER, cache
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
from datetime import datetime
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(cache_key:str='favicon'):
cache_favicon_name = await cache.get(cache_key)
if cache_favicon_name:
file_name = cache_favicon_name
else:
current_year = datetime.now().year
autumn_start = datetime(current_year, 9, 23)
autumn_end = datetime(current_year, 12, 21)
winter_start = datetime(current_year, 12, 21)
winter_end = datetime(current_year, 3, 20)
# Get the current date
current_date = datetime.now()
if autumn_start <= current_date <= autumn_end:
file_name = '1. autumn.gif'
elif current_date >= winter_start or current_date <= winter_end:
file_name = '2. winter.png'
else:
file_name = '0. default.svg'
await cache.set(cache_key, file_name, ttl=21600)
return await send_from_directory(current_app.static_folder, f'images/favicons/{file_name}')
async def favicon():
file_data = await current_app.convex.get_current_favicon()
return redirect(file_data['file_id'])
@basic_bp.route('/robots.txt', methods=['GET'])
@LIMITER.limit('3 per day')
async def robots():
return await send_from_directory(current_app.static_folder, f'robots.txt')
@basic_bp.route("/storage/<path:file_id>")
async def convex_storage_proxy(file_id:str):
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)
+256 -17
View File
@@ -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 dont 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',
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': '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!"},
header={'title': title, 'message': message},
file={'name': '504.gif', 'alt': "Hex Code running over a screen and ends with Error"},
), 504
)
response = await make_response(rendered, status_code)
response.headers['Retry-After'] = retry_after
return response
+104 -8
View File
@@ -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")
+121 -89
View File
@@ -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,44 +69,79 @@ 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)
stream = getattr(uploaded, 'stream', None)
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)
file_size_pretty = format_size(size_bytes)
try:
await current_app.convex.add_file(
file_name=fname,
file_size=file_size_pretty,
@@ -137,29 +151,47 @@ async def api_upload(user):
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)
try:
await current_app.convex.add_file(
file_name=fname,
file_size=file_size_pretty,
note=note,
content_type="text/plain",
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})
+9 -2
View File
@@ -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
+2 -2
View File
@@ -35,7 +35,7 @@
</td>
<td>{{ access.file_note }}</td>
<td><span class="badge">{{ access.status }}</span></td>
<td>{{ access.ip }}</td>
<td>{{ access.ip_address }}</td>
<td>{{ access.user_agent }}</td>
</tr>
{% endfor %}
@@ -65,7 +65,7 @@
const datetime = timeEl.getAttribute("datetime");
if (!datetime) return;
const date = new Date(datetime);
const date = new Date(Number.parseInt(datetime));
timeEl.title = date.toISOString();
timeEl.textContent = date.toLocaleString(undefined, {
+210
View File
@@ -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 %}
+210
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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 %}
+20 -10
View File
@@ -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',()=>{
+37
View File
@@ -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);
}
+55
View File
@@ -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

+31
View File
@@ -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
+24
View File
@@ -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
+18
View File
@@ -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}"
+6
View File
@@ -0,0 +1,6 @@
echo "UID=${UID}" > .env
docker-compose up -d
cd ..
WEB_DEBUG=true \
./run.py
+119
View File
@@ -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())
+106
View File
@@ -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'
+148
View File
@@ -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())
Generated
+440 -683
View File
File diff suppressed because it is too large Load Diff