Compare commits
40 Commits
b7ec488b44
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
60139f48f2
|
|||
|
ca85dc2265
|
|||
|
1dbf3b36ff
|
|||
|
2982d44e55
|
|||
|
cb6422aacb
|
|||
|
a73c0302a4
|
|||
|
4cefc4e0ad
|
|||
|
5b619670ee
|
|||
|
9c731d6e67
|
|||
| 26536a3cde | |||
|
42ce022d7b
|
|||
|
6ead4b8541
|
|||
|
0b81d3d803
|
|||
|
3d8d74785c
|
|||
|
1c58de68b6
|
|||
|
c28e4874d9
|
|||
|
680e8dafff
|
|||
|
b170fcfa98
|
|||
|
4773338ccc
|
|||
|
d9b7c88ccf
|
|||
|
7b77387182
|
|||
|
65951a23ce
|
|||
|
5bbc100d83
|
|||
|
cf489c9f4a
|
|||
|
eeda177182
|
|||
|
0681bd398c
|
|||
|
ea4738ad06
|
|||
|
51f02ff5c8
|
|||
|
b311bcae11
|
|||
|
063ff3ca58
|
|||
|
fc3a0219a0
|
|||
|
a6befb5aeb
|
|||
|
cc79e43b3c
|
|||
|
367e9fbdb6
|
|||
|
6137121209
|
|||
|
e8e37e8967
|
|||
|
3cd30d3bad
|
|||
|
bea2d98fa6
|
|||
|
d82ed3d43a
|
|||
|
e541000347
|
@@ -9,18 +9,22 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
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
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
token: '${{ secrets.ACTION_ACCESS_TOKEN }}'
|
token: '${{ secrets.ACTION_ACCESS_TOKEN }}'
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
lfs: true
|
lfs: true
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ vars.DOCKER_REGISTRY_URL }}
|
registry: ${{ vars.DOCKER_REGISTRY_URL }}
|
||||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||||
@@ -30,7 +34,7 @@ jobs:
|
|||||||
run: echo "REPO_OWNER_LC=$(echo '${{ gitea.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
run: echo "REPO_OWNER_LC=$(echo '${{ gitea.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build and push Docker image for latest tag
|
- name: Build and push Docker image for latest tag
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -4,3 +4,9 @@
|
|||||||
[submodule "my_helpers"]
|
[submodule "my_helpers"]
|
||||||
path = my_helpers
|
path = my_helpers
|
||||||
url = git@git.yiprawr.dev:daniel156161/python-helper-modules.git
|
url = git@git.yiprawr.dev:daniel156161/python-helper-modules.git
|
||||||
|
[submodule "quart_common"]
|
||||||
|
path = quart_common
|
||||||
|
url = git@git.yiprawr.dev:submodules/python-quart-common.git
|
||||||
|
[submodule "servicelink"]
|
||||||
|
path = servicelink
|
||||||
|
url = git@git.yiprawr.dev:submodules/servicelink.git
|
||||||
|
|||||||
+1
-1
@@ -10,4 +10,4 @@ RUN uv sync --no-config --frozen --compile-bytecode
|
|||||||
# Starten Sie Ihre Anwendung
|
# Starten Sie Ihre Anwendung
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uv", "run", "hypercorn", "run:app", "--bind", "0.0.0.0:8000", "--workers", "1", "--websocket-ping-interval", "20", "--access-logfile", "-"]
|
CMD [".venv/bin/hypercorn", "run:app", "--bind", "0.0.0.0:8000", "--workers", "1", "--websocket-ping-interval", "20", "--access-logfile", "-"]
|
||||||
|
|||||||
+1
-1
Submodule my_helpers updated: 4b582a3610...059338cffb
@@ -0,0 +1,67 @@
|
|||||||
|
from redis.asyncio import Redis as aioredis
|
||||||
|
from collections import defaultdict
|
||||||
|
import asyncio, time
|
||||||
|
|
||||||
|
class OrphanStorageIdRegistry:
|
||||||
|
def __init__(self, retention_seconds:int=600, redis_client:aioredis=None):
|
||||||
|
self.retention_seconds = max(60, int(retention_seconds))
|
||||||
|
self.redis = redis_client
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._store: dict[tuple[str, str], list[tuple[str, float]]] = defaultdict(list)
|
||||||
|
self._prefix = "upload:orphan:"
|
||||||
|
|
||||||
|
def _key(self, user_id:str, fingerprint:str) -> str:
|
||||||
|
return f"{self._prefix}{user_id}:{fingerprint}"
|
||||||
|
|
||||||
|
def _prune_locked(self, now:float):
|
||||||
|
threshold = now - self.retention_seconds
|
||||||
|
for key in list(self._store.keys()):
|
||||||
|
entries = [entry for entry in self._store[key] if entry[1] >= threshold]
|
||||||
|
if entries:
|
||||||
|
self._store[key] = entries
|
||||||
|
else:
|
||||||
|
self._store.pop(key, None)
|
||||||
|
|
||||||
|
async def remember(self, user_id:str, fingerprint:str|None, storage_id:str):
|
||||||
|
if not fingerprint or not storage_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.redis is not None:
|
||||||
|
key = self._key(user_id, fingerprint)
|
||||||
|
pipe = self.redis.pipeline()
|
||||||
|
pipe.lpush(key, storage_id)
|
||||||
|
pipe.expire(key, self.retention_seconds)
|
||||||
|
await pipe.execute()
|
||||||
|
return
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
now = time.time()
|
||||||
|
self._prune_locked(now)
|
||||||
|
self._store[(user_id, fingerprint)].append((storage_id, now))
|
||||||
|
|
||||||
|
async def pop_recent(self, user_id:str, fingerprint:str|None) -> str|None:
|
||||||
|
if not fingerprint:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.redis is not None:
|
||||||
|
key = self._key(user_id, fingerprint)
|
||||||
|
value = await self.redis.rpop(key)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return value.decode("utf-8", errors="ignore")
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
self._prune_locked(time.time())
|
||||||
|
entries = self._store.get((user_id, fingerprint))
|
||||||
|
if not entries:
|
||||||
|
return None
|
||||||
|
storage_id, _ = entries.pop()
|
||||||
|
if not entries:
|
||||||
|
self._store.pop((user_id, fingerprint), None)
|
||||||
|
return storage_id
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
if self.redis is not None:
|
||||||
|
await self.redis.aclose()
|
||||||
@@ -18,3 +18,10 @@ SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "USE_ENV_das_ist_ein_geheimer_schlüs
|
|||||||
API_GROUP = os.getenv("API_GROUP", 'NanoShare')
|
API_GROUP = os.getenv("API_GROUP", 'NanoShare')
|
||||||
|
|
||||||
THE_IP_BOT_MANAGER = TheIPManager()
|
THE_IP_BOT_MANAGER = TheIPManager()
|
||||||
|
|
||||||
|
SKIP_PATH_PREFIXES = ("/static", "/storage")
|
||||||
|
SKIP_PATHS = ("/favicon.ico",)
|
||||||
|
|
||||||
|
# Blocke IPs (Bots, Hackers)
|
||||||
|
BLOCKED_IPS_ACCESSING_TIMES = int(os.getenv("BLOCKE_IPS_AFTER_ACCESSING_HOWMANY_TIME", 5))
|
||||||
|
BLOCKED_IPS_STORED_TIMEFRAME = int(os.getenv("BLOCKE_IPS_STORE_KEYS_TIMEFRAME", 3600))
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
from aiologger.formatters.base import Formatter
|
from quart_common.web.logger import build_logger
|
||||||
from aiologger.handlers.streams import AsyncStreamHandler
|
|
||||||
from aiologger import Logger
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
formatter = Formatter(fmt="%(levelname)s %(module)s: %(message)s")
|
logger = build_logger(name="nanoshare")
|
||||||
handler = AsyncStreamHandler(stream=sys.stdout)
|
|
||||||
handler.formatter = formatter
|
|
||||||
|
|
||||||
logger = Logger(name="simple-picoshare", level="DEBUG" if os.getenv("WEB_DEBUG", False) == "true" else "INFO")
|
|
||||||
logger.handlers = [handler]
|
|
||||||
|
|||||||
+58
-17
@@ -1,7 +1,18 @@
|
|||||||
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.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.app.constens import SECRET_KEY, THE_IP_BOT_MANAGER
|
||||||
|
from my_modules.OrphanStorageIdRegistry import OrphanStorageIdRegistry
|
||||||
from my_modules.AsyncCache import AsyncCache
|
from my_modules.AsyncCache import AsyncCache
|
||||||
from my_modules.app.logger import logger
|
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 my_modules.db.ConvexDB import ConvexDB
|
||||||
|
|
||||||
from quart_session import Session
|
from quart_session import Session
|
||||||
@@ -11,8 +22,12 @@ import redis.asyncio as aioredis
|
|||||||
from quart import Quart
|
from quart import Quart
|
||||||
import os
|
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
|
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 * 1024
|
||||||
|
register_wide_event_logging(app, logger)
|
||||||
|
|
||||||
app.secret_key = SECRET_KEY
|
app.secret_key = SECRET_KEY
|
||||||
|
|
||||||
@@ -25,7 +40,7 @@ if os.getenv("VALKEY_HOST", None) is not None:
|
|||||||
password=os.getenv('VALKEY_CACHE_PASSWORD', ''),
|
password=os.getenv('VALKEY_CACHE_PASSWORD', ''),
|
||||||
host=os.getenv('VALKEY_HOST'),
|
host=os.getenv('VALKEY_HOST'),
|
||||||
port=os.getenv('VALKEY_PORT', 6379),
|
port=os.getenv('VALKEY_PORT', 6379),
|
||||||
db=os.getenv('VALKEY_DB', 0)
|
db=os.getenv('VALKEY_DB', 0),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cache = AsyncCache(
|
cache = AsyncCache(
|
||||||
@@ -34,17 +49,17 @@ else:
|
|||||||
|
|
||||||
if os.getenv("VALKEY_HOST", None) is not None:
|
if os.getenv("VALKEY_HOST", None) is not None:
|
||||||
app.config.from_mapping(
|
app.config.from_mapping(
|
||||||
SESSION_TYPE="redis",
|
SESSION_TYPE='redis',
|
||||||
SESSION_PERMANENT=True,
|
SESSION_PERMANENT=True,
|
||||||
SESSION_USE_SIGNER=True,
|
SESSION_USE_SIGNER=True,
|
||||||
SESSION_REDIS = aioredis.Redis(
|
SESSION_REDIS=aioredis.Redis(
|
||||||
username=os.getenv('VALKEY_SESSION_USER', None),
|
username=os.getenv('VALKEY_SESSION_USER', None),
|
||||||
password=os.getenv('VALKEY_SESSION_PASSWORD', None),
|
password=os.getenv('VALKEY_SESSION_PASSWORD', None),
|
||||||
host=os.getenv("VALKEY_HOST"),
|
host=os.getenv('VALKEY_HOST'),
|
||||||
port=os.getenv("VALKEY_PORT", 6379),
|
port=os.getenv('VALKEY_PORT', 6379),
|
||||||
db=os.getenv("VALKEY_DB", 0),
|
db=os.getenv('VALKEY_DB', 0),
|
||||||
decode_responses=True
|
decode_responses=True,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
app.config.from_mapping(
|
app.config.from_mapping(
|
||||||
@@ -57,17 +72,40 @@ LIMITER = Limiter(
|
|||||||
custom_limit_key,
|
custom_limit_key,
|
||||||
app=app,
|
app=app,
|
||||||
storage_uri=(
|
storage_uri=(
|
||||||
f"redis://{os.getenv('VALKEY_LIMITER_USER', '')}:{os.getenv('VALKEY_LIMITER_PASSWORD', '')}"
|
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)}"
|
f'@{os.getenv('VALKEY_HOST')}:{os.getenv('VALKEY_PORT', 6379)}/{os.getenv('VALKEY_DB', 0)}'
|
||||||
) if os.getenv("VALKEY_HOST") else None,
|
)
|
||||||
|
if os.getenv('VALKEY_HOST')
|
||||||
|
else None,
|
||||||
default_limits=[],
|
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
|
@app.before_serving
|
||||||
async def init_convex():
|
async def init_convex():
|
||||||
app.convex = ConvexDB(os.getenv("CONVEX_URL"))
|
await convex_runtime.start()
|
||||||
await app.convex.connect()
|
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('127.0.0.1')
|
||||||
THE_IP_BOT_MANAGER.add_always_allowed_ip(await get_my_ip_address())
|
THE_IP_BOT_MANAGER.add_always_allowed_ip(await get_my_ip_address())
|
||||||
@@ -81,5 +119,8 @@ async def init_convex():
|
|||||||
@app.after_serving
|
@app.after_serving
|
||||||
async def close_convex():
|
async def close_convex():
|
||||||
if app.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()
|
await logger.shutdown()
|
||||||
|
|||||||
+43
-31
@@ -1,28 +1,30 @@
|
|||||||
from my_helpers.ConvexDbBase import ConvexDbBase
|
from my_helpers.db.convex.ConvexDbBase import ConvexDbBase
|
||||||
|
from my_helpers.db.convex.ConvexRuntime import ConvexRuntime
|
||||||
|
|
||||||
from my_modules.app.logger import logger
|
from my_modules.app.logger import logger
|
||||||
|
|
||||||
from convex import ConvexError, ConvexExecutionError
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
class ConvexDB(ConvexDbBase):
|
class ConvexDB(ConvexDbBase):
|
||||||
service_namespace = 'nanoshare'
|
service_namespace = 'nanoshare'
|
||||||
|
|
||||||
def __init__(self, dsn:str):
|
def __init__(self, runtime:ConvexRuntime):
|
||||||
super().__init__(dsn=dsn, service=ConvexDB.service_namespace)
|
super().__init__(
|
||||||
|
runtime=runtime,
|
||||||
|
service=ConvexDB.service_namespace
|
||||||
|
)
|
||||||
|
|
||||||
# File Quary Functions
|
# File Quary Functions
|
||||||
async def get_file(self, file_id:str):
|
async def get_file(self, file_id:str):
|
||||||
data = await self.run_query_with_reconnection(
|
data = await self.run_query(
|
||||||
self.client.query,
|
name='files:getByFileId',
|
||||||
f"{self.service_namespace}/files:getByFileId",
|
|
||||||
args={ 'file_id': file_id }
|
args={ 'file_id': file_id }
|
||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def get_files(self, user_id:str):
|
async def get_files(self, user_id:str):
|
||||||
data = await self.run_query_with_reconnection(
|
data = await self.run_query(
|
||||||
self.client.query,
|
name='files:getAllNotExpired',
|
||||||
f"{self.service_namespace}/files:getAllNotExpired",
|
|
||||||
args={ 'user_id': user_id }
|
args={ 'user_id': user_id }
|
||||||
)
|
)
|
||||||
return [ {
|
return [ {
|
||||||
@@ -34,7 +36,7 @@ class ConvexDB(ConvexDbBase):
|
|||||||
"uploaded_at": int(x['uploaded_at']),
|
"uploaded_at": int(x['uploaded_at']),
|
||||||
} for x in data ]
|
} 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 = {
|
args = {
|
||||||
'file_name': file_name, 'file_size': file_size, 'content_type': content_type,
|
'file_name': file_name, 'file_size': file_size, 'content_type': content_type,
|
||||||
'note': note,
|
'note': note,
|
||||||
@@ -43,48 +45,58 @@ class ConvexDB(ConvexDbBase):
|
|||||||
if expires_at:
|
if expires_at:
|
||||||
args['expires_at'] = expires_at.isoformat()
|
args['expires_at'] = expires_at.isoformat()
|
||||||
|
|
||||||
data = await self.run_query_with_reconnection(
|
data = await self.run_mutation(
|
||||||
self.client.mutation,
|
name='files:addNewFile',
|
||||||
f"{self.service_namespace}/files:addNewFile",
|
|
||||||
args=args,
|
args=args,
|
||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def update_file(self, file_id:str, file_name:str, note:str, expires_at:datetime, user_id:str):
|
async def update_file(self, file_id:str, file_name:str, note:str, expires_at:datetime|None, user_id:str):
|
||||||
await self.run_query_with_reconnection(
|
args = {
|
||||||
self.client.mutation,
|
'file_id': file_id,
|
||||||
f"{self.service_namespace}/files:updateFile",
|
'file_name': file_name,
|
||||||
args={ 'file_id': file_id, 'file_name': file_name, 'note': note, 'expires_at': expires_at.isoformat(), 'user_id': user_id }
|
'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):
|
async def delete_file(self, file_id:str, user_id:str):
|
||||||
await self.run_query_with_reconnection(
|
await self.run_mutation(
|
||||||
self.client.mutation,
|
name='files:deleteFile',
|
||||||
f"{self.service_namespace}/files:deleteFile",
|
|
||||||
args={ 'file_id': file_id, 'user_id': user_id }
|
args={ 'file_id': file_id, 'user_id': user_id }
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_file_informations(self, file_id:str):
|
async def get_file_informations(self, file_id:str, user_id:str):
|
||||||
pass
|
data = await self.run_query(
|
||||||
|
name='files:getFileByIdAndUser',
|
||||||
|
args={ 'file_id': file_id, 'user_id': user_id }
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
# File Access Quary Functions
|
# File Access Quary Functions
|
||||||
async def add_file_access(self, file_id: str, ip_address:str, status:str, user_agent:str):
|
async def add_file_access(self, file_id: str, ip_address:str, status:str, user_agent:str):
|
||||||
data = await self.run_query_with_reconnection(
|
data = await self.run_mutation(
|
||||||
self.client.mutation,
|
name='access:addNewAccess',
|
||||||
f"{self.service_namespace}/access:addNewAccess",
|
|
||||||
args={ 'file_id': file_id, 'ip_address': ip_address, 'user_agent': str(user_agent), 'status': status }
|
args={ 'file_id': file_id, 'ip_address': ip_address, 'user_agent': str(user_agent), 'status': status }
|
||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def get_all_access(self, user_id:str):
|
async def get_all_access(self, user_id:str):
|
||||||
data = await self.run_query_with_reconnection(
|
data = await self.run_query(
|
||||||
self.client.query,
|
name='access:getAllByUser',
|
||||||
f"{self.service_namespace}/access:getAllByUser",
|
|
||||||
args={ 'user_id': user_id }
|
args={ 'user_id': user_id }
|
||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def get_file_access(self, file_id: str):
|
async def get_file_access(self, file_id:str, user_id:str):
|
||||||
|
return []
|
||||||
|
|
||||||
data = await self.run_query_with_reconnection(
|
data = await self.run_query_with_reconnection(
|
||||||
self.client.query_single,
|
self.client.query_single,
|
||||||
"""
|
"""
|
||||||
|
|||||||
+21
-158
@@ -1,164 +1,27 @@
|
|||||||
from my_modules.app.constens import THE_IP_BOT_MANAGER
|
from my_modules.app.constens import THE_IP_BOT_MANAGER
|
||||||
from my_modules.app.logger import logger
|
from my_modules.app.logger import logger
|
||||||
from my_modules.app.setup import LIMITER
|
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
|
verify_token = build_verify_token(logger=logger)
|
||||||
from functools import wraps
|
token_required = build_token_required(logger=logger, verify_token=verify_token)
|
||||||
from datetime import datetime
|
|
||||||
import asyncio, msgpack, json
|
|
||||||
|
|
||||||
def encode_object_default(obj):
|
apply_limit = build_apply_limit(
|
||||||
if isinstance(obj, datetime):
|
limiter=LIMITER,
|
||||||
return obj.strftime('%a, %d %b %Y %H:%M:%S %Z')
|
ip_bot_manager=THE_IP_BOT_MANAGER,
|
||||||
raise TypeError(f"Type {type(obj)} not serializable")
|
)
|
||||||
|
|
||||||
# Helper function to extract the token
|
feature_flag_required = build_feature_flag_required(
|
||||||
async def get_auth_token():
|
logger=logger,
|
||||||
auth_header = request.headers.get('Authorization')
|
)
|
||||||
if auth_header:
|
|
||||||
try:
|
|
||||||
return auth_header.split(" ")[1]
|
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def verify_token(token:str):
|
|
||||||
decoded_payload = await current_app.convex.decode_access_token_payload(access_token=token)
|
|
||||||
decoded_payload_error_state = decoded_payload.get('state', None)
|
|
||||||
|
|
||||||
if decoded_payload is None:
|
|
||||||
return {'error': "No Data from Database"}, 504
|
|
||||||
elif decoded_payload_error_state == 1:
|
|
||||||
await logger.error(decoded_payload.get('error'))
|
|
||||||
return {'error': 'Invalid access token'}, 401
|
|
||||||
elif decoded_payload_error_state == 2:
|
|
||||||
await logger.error(decoded_payload.get('error'))
|
|
||||||
return {'error': 'Wrong access token type'}, 401
|
|
||||||
elif decoded_payload_error_state == 3:
|
|
||||||
await logger.error(decoded_payload.get('error'))
|
|
||||||
return {'error': 'Refresh token not found', 'msg': 'Please login again and generate a new Token', 'url': url_for('auth_login.login')}, 403
|
|
||||||
elif decoded_payload_error_state == 4:
|
|
||||||
await logger.error(decoded_payload.get('error'))
|
|
||||||
return {'error': 'Refresh token expired'}, 401
|
|
||||||
|
|
||||||
return decoded_payload, 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
|
|
||||||
|
|
||||||
decoded_payload, status_code = await verify_token(token)
|
|
||||||
decoded_payload_error = decoded_payload.get('error', None)
|
|
||||||
if decoded_payload_error:
|
|
||||||
return jsonify(decoded_payload), status_code
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
import re
|
||||||
|
|
||||||
|
PRESET_H = re.compile(r"^(\d+)h$")
|
||||||
|
PRESET_D = re.compile(r"^(\d+)d$")
|
||||||
|
|
||||||
|
def parse_expires(value: str | None) -> datetime | None:
|
||||||
|
"""Parse expiration presets or ISO datetime."""
|
||||||
|
if not value or value == "never":
|
||||||
|
return None
|
||||||
|
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if m := PRESET_H.match(value):
|
||||||
|
return datetime.now(timezone.utc) + timedelta(hours=int(m.group(1)))
|
||||||
|
if m := PRESET_D.match(value):
|
||||||
|
return datetime.now(timezone.utc) + timedelta(days=int(m.group(1)))
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def ensure_utc(dt:datetime):
|
||||||
|
"""Ensure a timezone-aware UTC datetime or None."""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
def iso_stamp_filename(prefix: str, ext: str) -> str:
|
||||||
|
"""Generate timestamped filename, e.g. pasted-2025-10-23T121212Z.png"""
|
||||||
|
ts = datetime.now(timezone.utc).isoformat()
|
||||||
|
ts = ts.replace(":", "").split(".")[0]
|
||||||
|
if ts.endswith("+00:00"):
|
||||||
|
ts = ts.replace("+00:00", "Z")
|
||||||
|
return f"{prefix}-{ts}.{ext}"
|
||||||
|
|
||||||
|
def format_size(num_bytes: int) -> str:
|
||||||
|
"""Return a human-readable file size (e.g., '2.3 MB', '10 Bytes')."""
|
||||||
|
if num_bytes < 1024:
|
||||||
|
return f"{num_bytes} Byte{'s' if num_bytes != 1 else ''}"
|
||||||
|
|
||||||
|
units = ["KB", "MB", "GB", "TB", "PB", "EB"]
|
||||||
|
size = float(num_bytes)
|
||||||
|
for unit in units:
|
||||||
|
size /= 1024.0
|
||||||
|
if size < 1024.0 or unit == units[-1]:
|
||||||
|
# 1 decimal place; drop trailing .0 (optional)
|
||||||
|
val = f"{size:.1f}"
|
||||||
|
if val.endswith(".0"):
|
||||||
|
val = val[:-2]
|
||||||
|
return f"{val} {unit}"
|
||||||
|
return f"{num_bytes} Bytes" # fallback
|
||||||
|
|
||||||
+11
-56
@@ -1,38 +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
|
from flask_limiter import Limiter
|
||||||
from uuid import UUID
|
|
||||||
import subprocess, aiohttp
|
|
||||||
|
|
||||||
# Get IPs
|
|
||||||
def get_ip():
|
|
||||||
context = get_request_context()
|
|
||||||
if context:
|
|
||||||
xff = context.headers.get("X-Forwarded-For", "")
|
|
||||||
return xff.split(",")[0].strip() if xff else context.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
|
# Limiter Key Gen
|
||||||
def custom_limit_key():
|
def custom_limit_key():
|
||||||
@@ -52,25 +29,3 @@ def enforce_custom_limit(limiter:Limiter, key:str, limit_count: int = 3, window_
|
|||||||
current = limiter.storage.incr(key, expiry=window_sec)
|
current = limiter.storage.incr(key, expiry=window_sec)
|
||||||
if current > limit_count:
|
if current > limit_count:
|
||||||
raise LookupError("To Many 404 Requests")
|
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")
|
|
||||||
|
|
||||||
def get_request_context():
|
|
||||||
if has_request_context():
|
|
||||||
return request
|
|
||||||
elif has_websocket_context():
|
|
||||||
return websocket
|
|
||||||
return None
|
|
||||||
|
|
||||||
def is_valid_uuid(value: str) -> bool:
|
|
||||||
try:
|
|
||||||
UUID(value)
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|||||||
+12
-36
@@ -1,47 +1,23 @@
|
|||||||
from routes.handeling.errorsAndBots import maybe_a_hacker
|
from routes.handeling.errorsAndBots import maybe_a_hacker
|
||||||
|
|
||||||
from my_modules.app.constens import THE_IP_BOT_MANAGER
|
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.app.logger import logger
|
||||||
from my_modules.functions import get_ip
|
from my_modules.functions import get_ip
|
||||||
from my_modules.app.setup import app
|
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
|
from datetime import datetime
|
||||||
|
|
||||||
@app.before_request
|
custom_middleware = register_security_middleware(
|
||||||
async def custom_middleware():
|
app,
|
||||||
if session.get('user'): # only if session already has data, update redis expire time
|
logger=logger,
|
||||||
session.permanent = True
|
ip_bot_manager=THE_IP_BOT_MANAGER,
|
||||||
|
get_ip=get_ip,
|
||||||
client_ip = get_ip()
|
maybe_hacker_fn=maybe_a_hacker,
|
||||||
path = request.path
|
skip_paths=SKIP_PATHS,
|
||||||
method = request.method
|
skip_path_prefixes=SKIP_PATH_PREFIXES,
|
||||||
|
)
|
||||||
db_whitelisted_or_blocked = await current_app.convex.is_ip_address_whitelisted_or_blocked(ip_address=client_ip)
|
|
||||||
|
|
||||||
# Skip allowed IPs or non-critical assets
|
|
||||||
if (
|
|
||||||
db_whitelisted_or_blocked['whiteliste']
|
|
||||||
or THE_IP_BOT_MANAGER.is_client_ip_always_allowed(client_ip)
|
|
||||||
or "static" in path
|
|
||||||
or "favicon" in path
|
|
||||||
or "storage" in path
|
|
||||||
):
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2. If IP is already blocked
|
|
||||||
if db_whitelisted_or_blocked['blocked']:
|
|
||||||
await logger.error(f"[BLOCKED] {method} | {client_ip} tried {method} {path}")
|
|
||||||
await current_app.convex.increment_blocked_ip_address_access(ip_address=client_ip, method=method, path=path)
|
|
||||||
return await render_template("views/basics/blocked_access.htm", remote_addr=client_ip), 403
|
|
||||||
|
|
||||||
# 3. If path contains honeypot targets
|
|
||||||
if await current_app.convex.is_path_blocked(path=path):
|
|
||||||
await logger.warning(f"[HONEYPOT] {method} | {client_ip} accessed {path}")
|
|
||||||
await current_app.convex.increment_blocked_path_access(path=path)
|
|
||||||
return await maybe_a_hacker()
|
|
||||||
|
|
||||||
await logger.info(f"{method} | {client_ip} had accessed the Side {path}")
|
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
async def inject_context_data():
|
async def inject_context_data():
|
||||||
|
|||||||
+14
-52
@@ -1,66 +1,28 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "simple-picoshare"
|
name = "nanoshare"
|
||||||
version = "0.1.0"
|
version = "1.21.0"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiofiles==25.1.0",
|
"aiohttp==3.13.3",
|
||||||
"aiohappyeyeballs==2.6.1",
|
|
||||||
"aiohttp==3.13.1",
|
|
||||||
"aiologger==0.7.0",
|
"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",
|
"convex==0.7.0",
|
||||||
"cryptography==46.0.3",
|
"flask-limiter==4.1.1",
|
||||||
"deprecated==1.2.18",
|
|
||||||
"dotenv==0.9.9",
|
|
||||||
"flask==3.1.2",
|
|
||||||
"flask-limiter==4.0.0",
|
|
||||||
"frozenlist==1.8.0",
|
|
||||||
"h11==0.16.0",
|
|
||||||
"h2==4.3.0",
|
|
||||||
"hpack==4.1.0",
|
|
||||||
"httpcore==1.0.9",
|
|
||||||
"httpx==0.28.1",
|
"httpx==0.28.1",
|
||||||
"hypercorn==0.17.3",
|
"hypercorn==0.18.0",
|
||||||
"hyperframe==6.1.0",
|
"joserfc>=1.6.5",
|
||||||
"idna==3.11",
|
"python-dotenv==1.2.2",
|
||||||
"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",
|
|
||||||
"quart==0.20.0",
|
"quart==0.20.0",
|
||||||
"quart-flask-patch==0.3.0",
|
"quart-flask-patch==0.3.0",
|
||||||
"quart-session",
|
"quart-session",
|
||||||
"redis==7.0.0",
|
"redis==7.4.0",
|
||||||
"rich==14.2.0",
|
]
|
||||||
"setuptools==80.9.0",
|
|
||||||
"sniffio==1.3.1",
|
[tool.pytest.ini_options]
|
||||||
"typing-extensions==4.15.0",
|
testpaths = [
|
||||||
"werkzeug==3.1.3",
|
"tests",
|
||||||
"wrapt==1.17.3",
|
"quart_common/tests",
|
||||||
"wsproto==1.2.0",
|
|
||||||
"yarl==1.22.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.workspace]
|
[tool.uv.workspace]
|
||||||
|
|||||||
Submodule
+1
Submodule quart_common added at 77823f57d1
+24
-26
@@ -1,57 +1,55 @@
|
|||||||
aiofiles==25.1.0
|
aiofiles==25.1.0
|
||||||
aiohappyeyeballs==2.6.1
|
aiohappyeyeballs==2.6.1
|
||||||
aiohttp==3.13.1
|
aiohttp==3.13.3
|
||||||
aiologger==0.7.0
|
aiologger==0.7.0
|
||||||
aiosignal==1.4.0
|
aiosignal==1.4.0
|
||||||
anyio==4.11.0
|
anyio==4.13.0
|
||||||
attrs==25.4.0
|
attrs==26.1.0
|
||||||
authlib==1.6.5
|
authlib==1.6.9
|
||||||
blinker==1.9.0
|
blinker==1.9.0
|
||||||
certifi==2025.10.5
|
certifi==2026.2.25
|
||||||
cffi==2.0.0
|
cffi==2.0.0
|
||||||
click==8.3.0
|
click==8.3.1
|
||||||
convex==0.7.0
|
convex==0.7.0
|
||||||
cryptography==46.0.3
|
cryptography==46.0.6
|
||||||
deprecated==1.2.18
|
deprecated==1.3.1
|
||||||
dotenv==0.9.9
|
dotenv==0.9.9
|
||||||
flask==3.1.2
|
flask==3.1.3
|
||||||
flask-limiter==4.0.0
|
flask-limiter==4.1.1
|
||||||
frozenlist==1.8.0
|
frozenlist==1.8.0
|
||||||
gel==3.1.0
|
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
h2==4.3.0
|
h2==4.3.0
|
||||||
hpack==4.1.0
|
hpack==4.1.0
|
||||||
httpcore==1.0.9
|
httpcore==1.0.9
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
hypercorn==0.17.3
|
hypercorn==0.18.0
|
||||||
hyperframe==6.1.0
|
hyperframe==6.1.0
|
||||||
idna==3.11
|
idna==3.11
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
jinja2==3.1.6
|
jinja2==3.1.6
|
||||||
limits==5.6.0
|
limits==5.8.0
|
||||||
markdown-it-py==4.0.0
|
markdown-it-py==4.0.0
|
||||||
markupsafe==3.0.3
|
markupsafe==3.0.3
|
||||||
mdurl==0.1.2
|
mdurl==0.1.2
|
||||||
msgpack==1.1.2
|
msgpack==1.1.2
|
||||||
multidict==6.7.0
|
multidict==6.7.1
|
||||||
ordered-set==4.1.0
|
ordered-set==4.1.0
|
||||||
packaging==25.0
|
packaging==26.0
|
||||||
pip-autoremove==0.10.0
|
|
||||||
priority==2.0.0
|
priority==2.0.0
|
||||||
propcache==0.4.1
|
propcache==0.4.1
|
||||||
pycparser==2.23
|
pycparser==3.0
|
||||||
pygments==2.19.2
|
pygments==2.19.2
|
||||||
pyjwt==2.10.1
|
pyjwt==2.12.1
|
||||||
python-dotenv==1.1.1
|
python-dotenv==1.2.2
|
||||||
quart==0.20.0
|
quart==0.20.0
|
||||||
quart-flask-patch==0.3.0
|
quart-flask-patch==0.3.0
|
||||||
-e ./quart-session
|
-e ./quart-session
|
||||||
redis==7.0.0
|
redis==7.4.0
|
||||||
rich==14.2.0
|
rich==14.3.3
|
||||||
setuptools==80.9.0
|
setuptools==82.0.1
|
||||||
sniffio==1.3.1
|
sniffio==1.3.1
|
||||||
typing-extensions==4.15.0
|
typing-extensions==4.15.0
|
||||||
werkzeug==3.1.3
|
werkzeug==3.1.7
|
||||||
wrapt==1.17.3
|
wrapt==2.1.2
|
||||||
wsproto==1.2.0
|
wsproto==1.3.2
|
||||||
yarl==1.22.0
|
yarl==1.23.0
|
||||||
|
|||||||
+18
-1
@@ -1,6 +1,23 @@
|
|||||||
|
from my_modules.app.setup import cache, LIMITER
|
||||||
|
from my_modules.app.constens import API_GROUP
|
||||||
|
from my_modules.app.logger import logger
|
||||||
|
from .handeling.errorsAndBots import auth_error
|
||||||
|
|
||||||
from .handeling.basics import basic_bp
|
from .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.main import side_main_bp
|
||||||
|
|
||||||
from .side.upload import upload_bp
|
from .side.upload import upload_bp
|
||||||
|
|
||||||
|
# Health
|
||||||
|
from .api.health import health_bp
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from my_modules.decoratory.header import format_response, feature_flag_required
|
||||||
|
from my_modules.app.setup import LIMITER
|
||||||
|
from my_modules.app.logger import logger
|
||||||
|
from quart_common.web.wide_event import add_wide_event_context
|
||||||
|
|
||||||
|
from quart import Blueprint, current_app
|
||||||
|
|
||||||
|
health_bp = Blueprint("health", __name__)
|
||||||
|
|
||||||
|
@health_bp.route("/convex", methods=["GET"])
|
||||||
|
@LIMITER.limit("30 per minute")
|
||||||
|
@feature_flag_required("convex_health", fallback=False, status_code=404)
|
||||||
|
@format_response
|
||||||
|
async def get_convex_health_for_api():
|
||||||
|
add_wide_event_context(health={"check": "convex"})
|
||||||
|
runtime = getattr(current_app, "convex_runtime", None)
|
||||||
|
if runtime is None:
|
||||||
|
add_wide_event_context(health={"status": "unavailable", "reason": "runtime_missing"})
|
||||||
|
return {
|
||||||
|
"status": "unavailable",
|
||||||
|
"description": "Convex runtime is not attached to app",
|
||||||
|
}, 503
|
||||||
|
|
||||||
|
is_alive = await runtime.is_alive()
|
||||||
|
metrics = runtime.get_metrics()
|
||||||
|
add_wide_event_context(health={"status": "ok" if is_alive else "unhealthy"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"alive": is_alive,
|
||||||
|
"metrics": metrics,
|
||||||
|
}, 200 if is_alive else 503
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
'''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
|
||||||
|
|
||||||
|
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, bearer_verifier, create_link_blueprint
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
verify = bearer_verifier(_decode_access_token, require_scope=MESH_SCOPE)
|
||||||
|
link_bp = create_link_blueprint(router, verify=verify, limiter=LIMITER.limit('30 per minute'), max_body=MAX_RPC_BODY)
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
from my_modules.app.setup import cache, LIMITER
|
|
||||||
from my_modules.app.constens import API_GROUP
|
|
||||||
from my_modules.app.logger import logger
|
|
||||||
|
|
||||||
from quart import Blueprint, redirect, url_for, session, request, render_template, abort, make_response, current_app
|
|
||||||
|
|
||||||
from authlib.jose import JsonWebKey, JoseError, jwt as authlib_jwt
|
|
||||||
from authlib.integrations.httpx_client import AsyncOAuth2Client
|
|
||||||
from jwt import InvalidTokenError
|
|
||||||
|
|
||||||
import httpx, uuid, os
|
|
||||||
|
|
||||||
auth_login_bp = Blueprint('auth_login', __name__)
|
|
||||||
|
|
||||||
# OAuth
|
|
||||||
OIDC_CLIENT_ID = os.getenv('OIDC_CLIENT_ID', '')
|
|
||||||
OIDC_CLIENT_SECRET = os.getenv('OIDC_CLIENT_SECRET', '')
|
|
||||||
OIDC_METADATA_URL = os.getenv('OIDC_METADATA_URL', '')
|
|
||||||
REDIRECT_URI_SCHEME = os.getenv('REDIRECT_URI_SCHEME', 'http')
|
|
||||||
|
|
||||||
async def get_oidc_metadata():
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(OIDC_METADATA_URL)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
@auth_login_bp.route('/', methods=['GET'])
|
|
||||||
@LIMITER.limit("5 per minute; 30 per hour")
|
|
||||||
async def login():
|
|
||||||
metadata = await get_oidc_metadata()
|
|
||||||
|
|
||||||
auth_id = request.cookies.get('auth_id') or str(uuid.uuid4())
|
|
||||||
nonce = str(uuid.uuid4())
|
|
||||||
|
|
||||||
await cache.set(f"oauth:nonce:{auth_id}", nonce, ttl=3603)
|
|
||||||
|
|
||||||
client = AsyncOAuth2Client(
|
|
||||||
client_id=OIDC_CLIENT_ID,
|
|
||||||
client_secret=OIDC_CLIENT_SECRET,
|
|
||||||
redirect_uri=url_for('auth_login.auth_callback', _external=True, _scheme=REDIRECT_URI_SCHEME),
|
|
||||||
scope='openid profile email',
|
|
||||||
)
|
|
||||||
|
|
||||||
uri, state = client.create_authorization_url(
|
|
||||||
metadata['authorization_endpoint'],
|
|
||||||
nonce=nonce,
|
|
||||||
state=auth_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await make_response(redirect(uri))
|
|
||||||
response.set_cookie('auth_id', auth_id, max_age=3600, httponly=True, secure=True, samesite='Lax')
|
|
||||||
return response
|
|
||||||
|
|
||||||
@auth_login_bp.route('/logout', methods=['GET'])
|
|
||||||
@LIMITER.limit("10 per minute")
|
|
||||||
async def logout():
|
|
||||||
user = session.get('user')
|
|
||||||
if user:
|
|
||||||
await logger.info(f'logging out user: {user}')
|
|
||||||
session.pop('user', None)
|
|
||||||
|
|
||||||
session.clear()
|
|
||||||
return redirect(url_for('side_main.index'))
|
|
||||||
|
|
||||||
@auth_login_bp.route('/callback', methods=['GET'])
|
|
||||||
@LIMITER.limit("5 per minute")
|
|
||||||
async def auth_callback():
|
|
||||||
try:
|
|
||||||
auth_id = request.args.get('state')
|
|
||||||
code = request.args.get('code')
|
|
||||||
nonce = await cache.get(f"oauth:nonce:{auth_id}")
|
|
||||||
|
|
||||||
if not nonce:
|
|
||||||
await logger.error('Nonce not found')
|
|
||||||
return await render_template('views/api/token.htm', error='Nonce not found'), 400
|
|
||||||
|
|
||||||
await cache.delete(f"oauth:nonce:{auth_id}")
|
|
||||||
|
|
||||||
metadata = await get_oidc_metadata()
|
|
||||||
client = AsyncOAuth2Client(
|
|
||||||
client_id=OIDC_CLIENT_ID,
|
|
||||||
client_secret=OIDC_CLIENT_SECRET,
|
|
||||||
redirect_uri=url_for('auth_login.auth_callback', _external=True, _scheme=REDIRECT_URI_SCHEME),
|
|
||||||
scope='openid profile email',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Exchange code for token
|
|
||||||
token = await client.fetch_token(
|
|
||||||
metadata['token_endpoint'],
|
|
||||||
code=code,
|
|
||||||
grant_type='authorization_code'
|
|
||||||
)
|
|
||||||
await logger.debug(f'Auth Callback | token: {token}')
|
|
||||||
|
|
||||||
# Decode ID token
|
|
||||||
id_token = token.get('id_token')
|
|
||||||
if not id_token:
|
|
||||||
await logger.error('ID token missing in OAuth response')
|
|
||||||
return await render_template('views/api/token.htm', error='ID token missing'), 400
|
|
||||||
|
|
||||||
# Fetch the JWKs to verify signature
|
|
||||||
async with httpx.AsyncClient() as http_client:
|
|
||||||
jwks_resp = await http_client.get(metadata['jwks_uri'])
|
|
||||||
jwks = jwks_resp.json()
|
|
||||||
|
|
||||||
keys = JsonWebKey.import_key_set(jwks)
|
|
||||||
claims = authlib_jwt.decode(id_token, key=keys)
|
|
||||||
|
|
||||||
try:
|
|
||||||
claims.validate_aud()
|
|
||||||
claims.validate()
|
|
||||||
except JoseError as e:
|
|
||||||
await logger.error(f"JWT validation error: {e}")
|
|
||||||
return await render_template('views/api/token.htm', error=str(e)), 400
|
|
||||||
|
|
||||||
if claims.get('nonce') != nonce:
|
|
||||||
await logger.error('Nonce mismatch in ID token')
|
|
||||||
return await render_template('views/api/token.htm', error='Invalid nonce'), 400
|
|
||||||
|
|
||||||
await logger.info(f'Auth Callback | user_info: {claims}')
|
|
||||||
|
|
||||||
if API_GROUP not in claims.get('groups', []):
|
|
||||||
await logger.error("You don't have Permissions to Access this API")
|
|
||||||
return await render_template('views/api/token.htm', error="You don't have Permissions to Access this API"), 403
|
|
||||||
|
|
||||||
session['user'] = claims
|
|
||||||
response = await make_response(redirect(url_for('side_main.index')))
|
|
||||||
response.set_cookie('auth_id', '', max_age=0, httponly=True, secure=True, samesite='Lax')
|
|
||||||
return response
|
|
||||||
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
await logger.error(f"HTTP error during token exchange: {e}")
|
|
||||||
return await render_template('views/api/token.htm', error="Token exchange failed"), 500
|
|
||||||
except InvalidTokenError as e:
|
|
||||||
await logger.error(f"Invalid ID Token: {e}")
|
|
||||||
return await render_template('views/api/token.htm', error="Invalid ID Token"), 400
|
|
||||||
except Exception as e:
|
|
||||||
await logger.exception("Unknown error during OAuth callback")
|
|
||||||
return await render_template('views/api/token.htm', error=str(e)), 500
|
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
from my_modules.app.setup import LIMITER
|
from my_modules.app.setup import LIMITER
|
||||||
from my_modules.functions import is_valid_uuid
|
from my_modules.functions import is_valid_uuid
|
||||||
|
|
||||||
from quart import Blueprint, send_from_directory, current_app, Response, redirect, abort
|
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 = Blueprint('basic', __name__)
|
||||||
|
|
||||||
@basic_bp.route('/favicon', methods=['GET'])
|
|
||||||
@basic_bp.route('/favicon.ico', 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
|
@LIMITER.exempt
|
||||||
async def favicon():
|
async def favicon():
|
||||||
file_data = await current_app.convex.get_current_favicon()
|
file_data = await current_app.convex.get_current_favicon()
|
||||||
@@ -22,7 +27,20 @@ async def convex_storage_proxy(file_id:str):
|
|||||||
if not is_valid_uuid(file_id):
|
if not is_valid_uuid(file_id):
|
||||||
return abort(404, "Not a valid uuid")
|
return abort(404, "Not a valid uuid")
|
||||||
|
|
||||||
return Response(
|
query_keys = set(request.args.keys())
|
||||||
current_app.convex.stream_from_storage(file_id, add_api_path=True),
|
if query_keys - {"component"}:
|
||||||
mimetype="application/octet-stream"
|
return abort(400, "Only the component query parameter is allowed")
|
||||||
)
|
|
||||||
|
component_values = request.args.getlist("component")
|
||||||
|
if len(component_values) > 1:
|
||||||
|
return abort(400, "Only one component query parameter is allowed")
|
||||||
|
|
||||||
|
storage_file_id = file_id
|
||||||
|
if component_values:
|
||||||
|
storage_file_id = f"{file_id}?{urlencode({'component': component_values[0]})}"
|
||||||
|
|
||||||
|
stream, headers = await current_app.convex.open_storage_stream(storage_file_id, add_api_path=True)
|
||||||
|
if 'Content-Type' not in headers:
|
||||||
|
headers['Content-Type'] = 'application/octet-stream'
|
||||||
|
|
||||||
|
return Response(stream, headers=headers)
|
||||||
|
|||||||
@@ -1,15 +1,136 @@
|
|||||||
|
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.setup import app, LIMITER
|
||||||
from my_modules.app.logger import logger
|
from my_modules.app.logger import logger
|
||||||
|
|
||||||
from quart import request, render_template, jsonify, current_app, make_response
|
from quart import request, render_template, jsonify, current_app, make_response, g
|
||||||
from my_modules.functions import get_ip, enforce_custom_limit
|
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)
|
@app.errorhandler(401)
|
||||||
async def handle_unauthorized(e):
|
async def handle_unauthorized(e):
|
||||||
if request.path.startswith("/api"):
|
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
|
return jsonify({"error": "Unauthorized Access", "message": "Gandalf has spoken: You shall not pass… until you log in."}), 401
|
||||||
|
|
||||||
await logger.error(e)
|
await log_when_wide_event_disabled(logger, "error", e)
|
||||||
return await render_template('views/basics/error.htm',
|
return await render_template('views/basics/error.htm',
|
||||||
title='Unauthorized Access',
|
title='Unauthorized Access',
|
||||||
header={'title': '401 - Unauthorized', 'message': "Gandalf has spoken: You shall not pass… until you log in."},
|
header={'title': '401 - Unauthorized', 'message': "Gandalf has spoken: You shall not pass… until you log in."},
|
||||||
@@ -23,11 +144,26 @@ async def not_found(e):
|
|||||||
except LookupError as e:
|
except LookupError as e:
|
||||||
return await to_many_requests(e)
|
return await to_many_requests(e)
|
||||||
|
|
||||||
if request.path.startswith("/api"):
|
context = get_request_context()
|
||||||
return jsonify({"error": "Page Not Found", "message": "Oops! The page you are looking for does not exist."}), 404
|
error_description = str(getattr(e, "description", ""))
|
||||||
|
is_feature_flag_disabled_404 = error_description.startswith(FEATURE_FLAG_DISABLED_PREFIX)
|
||||||
|
|
||||||
await logger.error(f"[404] Page Not Found: {request.path}")
|
if (
|
||||||
await current_app.convex.increment_page_not_found_error(path=request.path, status=404)
|
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',
|
return await render_template('views/basics/error.htm',
|
||||||
title='Page Not Found',
|
title='Page Not Found',
|
||||||
@@ -37,16 +173,20 @@ async def not_found(e):
|
|||||||
|
|
||||||
@app.errorhandler(418)
|
@app.errorhandler(418)
|
||||||
async def maybe_a_hacker(e=None):
|
async def maybe_a_hacker(e=None):
|
||||||
|
add_wide_event_context(security={"blocked": True, "block_reason": "honeypot"})
|
||||||
try:
|
try:
|
||||||
enforce_custom_limit(LIMITER, "BotScan", 5, 120)
|
enforce_custom_limit(LIMITER, "BotScan", BLOCKED_IPS_ACCESSING_TIMES, BLOCKED_IPS_STORED_TIMEFRAME)
|
||||||
except LookupError as e:
|
except LookupError as e:
|
||||||
client_ip=get_ip()
|
client_ip=get_ip()
|
||||||
await current_app.convex.increment_blocked_ip_address_access(
|
await _safe_convex_error_tracking(
|
||||||
ip_address=client_ip,
|
"honeypot_ip_increment",
|
||||||
method=request.method,
|
current_app.convex.increment_blocked_ip_address_access(
|
||||||
path=request.path,
|
ip_address=client_ip,
|
||||||
|
method=request.method,
|
||||||
|
path=request.path,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await logger.warning(f"[HONEYPOT] Blocked {client_ip} after accessing {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)
|
return await to_many_requests(e)
|
||||||
|
|
||||||
rendered = await render_template('views/basics/error.htm',
|
rendered = await render_template('views/basics/error.htm',
|
||||||
@@ -63,9 +203,11 @@ async def maybe_a_hacker(e=None):
|
|||||||
|
|
||||||
@app.errorhandler(429)
|
@app.errorhandler(429)
|
||||||
async def to_many_requests(e):
|
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!"
|
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 jsonify({"error": "Too Many Requests - YOU SHALL NOT PASS (for now)", "message": message}), 429
|
||||||
|
|
||||||
return await render_template('views/basics/error.htm',
|
return await render_template('views/basics/error.htm',
|
||||||
@@ -74,33 +216,94 @@ async def to_many_requests(e):
|
|||||||
file={'name': '429_JimCarrey.gif', 'alt': "Jim Carrey Tips very fast on a computer keyboard"},
|
file={'name': '429_JimCarrey.gif', 'alt': "Jim Carrey Tips very fast on a computer keyboard"},
|
||||||
), 429
|
), 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)
|
@app.errorhandler(500)
|
||||||
async def internal_server_error(e):
|
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:
|
try:
|
||||||
enforce_custom_limit(LIMITER, "500")
|
enforce_custom_limit(LIMITER, "500")
|
||||||
except LookupError as e:
|
except LookupError as e:
|
||||||
return await to_many_requests(e)
|
return await to_many_requests(e)
|
||||||
|
|
||||||
if request.path.startswith("/api"):
|
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
|
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 logger.error(e)
|
await log_when_wide_event_disabled(logger, "error", e)
|
||||||
return await render_template('views/basics/error.htm',
|
return await render_template('views/basics/error.htm',
|
||||||
title='Internal Server Error',
|
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!"},
|
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!?!"},
|
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
|
), 500
|
||||||
|
|
||||||
|
@app.errorhandler(503)
|
||||||
@app.errorhandler(504)
|
@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:
|
try:
|
||||||
enforce_custom_limit(LIMITER, "504")
|
enforce_custom_limit(LIMITER, str(status_code))
|
||||||
except LookupError as e:
|
except LookupError as e:
|
||||||
return await to_many_requests(e)
|
return await to_many_requests(e)
|
||||||
|
|
||||||
await logger.error(e)
|
retry_after = str(DATABASE_RETRY_AFTER_SECS)
|
||||||
return await render_template('views/basics/error.htm',
|
title = f'{status_code} - Database Error'
|
||||||
title='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': '504 - Database Error', 'message': "It looks like something is broke on our end... but don't worry, we're fixing it! Either way, thanks for helping us find new ways to crash our system. Stay curious, hacker-friend!"},
|
|
||||||
file={'name': '504.gif', 'alt': "Hex Code running over a screen and ends with Error"},
|
await log_when_wide_event_disabled(logger, "error", e)
|
||||||
), 504
|
context = get_request_context()
|
||||||
|
if context.path.startswith("/api"):
|
||||||
|
response = await make_response(jsonify({"error": "Database Error", "message": message}), status_code)
|
||||||
|
else:
|
||||||
|
rendered = await render_template('views/basics/error.htm',
|
||||||
|
title='Database Error',
|
||||||
|
header={'title': title, 'message': message},
|
||||||
|
file={'name': '504.gif', 'alt': "Hex Code running over a screen and ends with Error"},
|
||||||
|
)
|
||||||
|
response = await make_response(rendered, status_code)
|
||||||
|
|
||||||
|
response.headers['Retry-After'] = retry_after
|
||||||
|
return response
|
||||||
|
|||||||
+104
-8
@@ -2,10 +2,34 @@ from my_modules.decoratory.header import login_required
|
|||||||
from my_modules.functions import get_ip
|
from my_modules.functions import get_ip
|
||||||
from my_modules.app.setup import LIMITER
|
from my_modules.app.setup import LIMITER
|
||||||
from my_modules.app.logger import logger
|
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('/')
|
@side_main_bp.route('/')
|
||||||
@LIMITER.limit("10 per minute;50 per hour")
|
@LIMITER.limit("10 per minute;50 per hour")
|
||||||
@@ -17,34 +41,103 @@ async def index():
|
|||||||
@side_main_bp.route('/access')
|
@side_main_bp.route('/access')
|
||||||
@login_required
|
@login_required
|
||||||
async def access_list(user):
|
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'])
|
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)
|
return await render_template("views/webpage/access/list.htm", access_logs=access_data)
|
||||||
|
|
||||||
@side_main_bp.route('/files')
|
@side_main_bp.route('/files')
|
||||||
@login_required
|
@login_required
|
||||||
async def files_list(user):
|
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'])
|
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')
|
@side_main_bp.route('/files/<path:file_id>/info')
|
||||||
@login_required
|
@login_required
|
||||||
async def file_info(file_id, user):
|
async def file_info(file_id, user):
|
||||||
files_data = await current_app.convex.get_files(user_id=user['sub'])
|
add_wide_event_context(nanoshare={"operation": "file_info", "file_id": file_id})
|
||||||
return await render_template("views/webpage/files/info.htm", files=files_data)
|
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
|
@login_required
|
||||||
async def file_edit(file_id, user):
|
async def file_edit(file_id, user):
|
||||||
files_data = await current_app.convex.get_files(user_id=user['sub'])
|
add_wide_event_context(nanoshare={"operation": "file_edit", "file_id": file_id})
|
||||||
return await render_template("views/webpage/files/edit.htm", files=files_data)
|
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>")
|
@side_main_bp.route("/-<file_id>")
|
||||||
@LIMITER.limit("10 per minute;500 per hour;")
|
@LIMITER.limit("10 per minute;500 per hour;")
|
||||||
async def serve_file(file_id: str):
|
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)
|
file_data = await current_app.convex.get_file(file_id=file_id)
|
||||||
disable_logging = False
|
disable_logging = False
|
||||||
|
|
||||||
if not file_data:
|
if not file_data:
|
||||||
|
add_wide_event_context(nanoshare={"operation_status": "not_found"})
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
user = session.get('user')
|
user = session.get('user')
|
||||||
@@ -52,6 +145,7 @@ async def serve_file(file_id: str):
|
|||||||
disable_logging = True
|
disable_logging = True
|
||||||
|
|
||||||
if file_data.get("expired", None):
|
if file_data.get("expired", None):
|
||||||
|
add_wide_event_context(nanoshare={"operation_status": "expired", "owner_request": disable_logging})
|
||||||
if not 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")
|
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={
|
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"}
|
force_download = request.args.get("download") in {"1", "true", "yes"}
|
||||||
|
|
||||||
if not file_data.get('db_image_url', None):
|
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:
|
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")
|
await current_app.convex.add_file_access(file_id=file_id, ip_address=get_ip(), user_agent=request.user_agent, status="error")
|
||||||
abort(404)
|
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:
|
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")
|
await current_app.convex.add_file_access(file_id=file_id, ip_address=get_ip(), user_agent=request.user_agent, status="ok")
|
||||||
|
|
||||||
|
|||||||
+139
-101
@@ -1,60 +1,20 @@
|
|||||||
from my_modules.decoratory.header import login_required
|
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 quart import Blueprint, request, jsonify, current_app
|
||||||
from datetime import datetime, timedelta, timezone
|
import asyncio, hashlib
|
||||||
import aiofiles, asyncio, re
|
|
||||||
|
|
||||||
upload_bp = Blueprint("upload_bp", __name__)
|
upload_bp = Blueprint('upload_bp', __name__)
|
||||||
|
|
||||||
# --- Helpers -----------------------------------------------------
|
# --- 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 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:
|
async def read_all(uploaded) -> bytes:
|
||||||
"""Read all bytes from an uploaded file, handling sync or async .read()."""
|
"""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:
|
if reader is None:
|
||||||
return b""
|
return b''
|
||||||
if asyncio.iscoroutinefunction(reader):
|
if asyncio.iscoroutinefunction(reader):
|
||||||
return await reader()
|
return await reader()
|
||||||
|
|
||||||
@@ -63,17 +23,41 @@ async def read_all(uploaded) -> bytes:
|
|||||||
return await data
|
return await data
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def ensure_utc(dt:datetime):
|
|
||||||
"""Ensure a timezone-aware UTC datetime or None."""
|
async def fingerprint_stream(stream, chunk_size:int=1024 * 1024) -> tuple[str|None, int|None]:
|
||||||
if dt is None:
|
if not hasattr(stream, 'seek') or not hasattr(stream, 'tell'):
|
||||||
return None
|
return None, None
|
||||||
if dt.tzinfo is None:
|
|
||||||
return dt.replace(tzinfo=timezone.utc)
|
try:
|
||||||
return dt.astimezone(timezone.utc)
|
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 ------------------------------------------------------
|
# --- Routes ------------------------------------------------------
|
||||||
|
|
||||||
@upload_bp.post("/api/upload")
|
@upload_bp.post('/api/upload')
|
||||||
@login_required
|
@login_required
|
||||||
async def api_upload(user):
|
async def api_upload(user):
|
||||||
"""
|
"""
|
||||||
@@ -85,75 +69,129 @@ async def api_upload(user):
|
|||||||
"""
|
"""
|
||||||
form = await request.form
|
form = await request.form
|
||||||
files = await request.files
|
files = await request.files
|
||||||
note = form.get("note", "")
|
note = form.get('note', '')
|
||||||
expires_raw = form.get("expires", "")
|
expires_raw = form.get('expires', '')
|
||||||
text = form.get("text", "")
|
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))
|
expires_at_dt = ensure_utc(parse_expires(expires_raw))
|
||||||
|
|
||||||
if not uploaded and not text.strip():
|
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
|
content_type = None
|
||||||
|
|
||||||
# --- binary upload path ---
|
# --- binary upload path ---
|
||||||
if uploaded:
|
if uploaded:
|
||||||
fname = uploaded.filename or ""
|
fname = uploaded.filename or ''
|
||||||
ctype = uploaded.mimetype or "application/octet-stream"
|
add_wide_event_context(nanoshare={"upload_type": "file", "filename_present": bool(fname)})
|
||||||
|
ctype = uploaded.mimetype or 'application/octet-stream'
|
||||||
content_type = ctype
|
content_type = ctype
|
||||||
|
storage_id = None
|
||||||
|
size_bytes = 0
|
||||||
|
fingerprint = None
|
||||||
|
reused_orphan_storage_id = False
|
||||||
|
|
||||||
# generate filename if missing/placeholder
|
# generate filename if missing/placeholder
|
||||||
if not fname or fname.lower() in {"blob", "file"}:
|
if not fname or fname.lower() in {'blob', 'file'}:
|
||||||
ext = {
|
ext = {
|
||||||
"image/png": "png",
|
'image/png': 'png',
|
||||||
"image/jpeg": "jpg",
|
'image/jpeg': 'jpg',
|
||||||
"image/gif": "gif",
|
'image/gif': 'gif',
|
||||||
"image/webp": "webp",
|
'image/webp': 'webp',
|
||||||
"application/pdf": "pdf",
|
'application/pdf': 'pdf',
|
||||||
"text/plain": "txt",
|
'text/plain': 'txt',
|
||||||
}.get(ctype, "bin")
|
}.get(ctype, 'bin')
|
||||||
fname = iso_stamp_filename("pasted", ext)
|
fname = iso_stamp_filename('pasted', ext)
|
||||||
|
|
||||||
data = await read_all(uploaded)
|
stream = getattr(uploaded, 'stream', None)
|
||||||
|
|
||||||
storage_id = await current_app.convex.send_to_storage(data=data, content_type=content_type)
|
if stream is not None:
|
||||||
|
fingerprint, detected_size = await fingerprint_stream(stream)
|
||||||
|
size_bytes = detected_size or 0
|
||||||
|
storage_id = (
|
||||||
|
await orphan_registry.pop_recent(user['sub'], fingerprint)
|
||||||
|
if orphan_registry
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if storage_id:
|
||||||
|
reused_orphan_storage_id = True
|
||||||
|
add_wide_event_context(nanoshare={"reused_orphan_storage_id": True})
|
||||||
|
else:
|
||||||
|
storage_id, sent_size = await current_app.convex.send_stream_to_storage(stream=stream,content_type=content_type)
|
||||||
|
size_bytes = sent_size
|
||||||
|
else:
|
||||||
|
data = await read_all(uploaded)
|
||||||
|
fingerprint = fingerprint_bytes(data)
|
||||||
|
storage_id = (
|
||||||
|
await orphan_registry.pop_recent(user['sub'], fingerprint)
|
||||||
|
if orphan_registry
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if storage_id:
|
||||||
|
reused_orphan_storage_id = True
|
||||||
|
add_wide_event_context(nanoshare={"reused_orphan_storage_id": True})
|
||||||
|
else:
|
||||||
|
storage_id = await current_app.convex.send_to_storage(data=data, content_type=content_type)
|
||||||
|
size_bytes = len(data)
|
||||||
|
|
||||||
size_bytes = len(data)
|
|
||||||
file_size_pretty = format_size(size_bytes)
|
file_size_pretty = format_size(size_bytes)
|
||||||
|
|
||||||
await current_app.convex.add_file(
|
try:
|
||||||
file_name=fname,
|
await current_app.convex.add_file(
|
||||||
file_size=file_size_pretty,
|
file_name=fname,
|
||||||
note=note,
|
file_size=file_size_pretty,
|
||||||
content_type=content_type,
|
note=note,
|
||||||
expires_at=expires_at_dt,
|
content_type=content_type,
|
||||||
storage_id=storage_id,
|
expires_at=expires_at_dt,
|
||||||
user_id=user['sub'],
|
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 ---
|
# --- text upload path ---
|
||||||
elif text.strip():
|
elif text.strip():
|
||||||
data = text.encode("utf-8")
|
add_wide_event_context(nanoshare={"upload_type": "text"})
|
||||||
fname = iso_stamp_filename("pasted", "txt")
|
data = text.encode('utf-8')
|
||||||
path = current_app.upload_folder / fname
|
fname = iso_stamp_filename('pasted', 'txt')
|
||||||
|
fingerprint = fingerprint_bytes(data)
|
||||||
async with aiofiles.open(path, "wb") as f:
|
storage_id = (
|
||||||
await f.write(data)
|
await orphan_registry.pop_recent(user['sub'], fingerprint)
|
||||||
|
if orphan_registry
|
||||||
storage_id = await current_app.convex.send_to_storage(data=data, content_type="text/plain")
|
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)
|
size_bytes = len(data)
|
||||||
file_size_pretty = format_size(size_bytes)
|
file_size_pretty = format_size(size_bytes)
|
||||||
|
|
||||||
await current_app.convex.add_file(
|
try:
|
||||||
file_name=fname,
|
await current_app.convex.add_file(
|
||||||
file_size=file_size_pretty,
|
file_name=fname,
|
||||||
note=note,
|
file_size=file_size_pretty,
|
||||||
content_type="text/plain",
|
note=note,
|
||||||
expires_at=expires_at_dt,
|
content_type='text/plain',
|
||||||
storage_id=storage_id,
|
expires_at=expires_at_dt,
|
||||||
user_id=user['sub'],
|
storage_id=storage_id,
|
||||||
)
|
user_id=user['sub'],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
if not reused_orphan_storage_id and orphan_registry:
|
||||||
|
await orphan_registry.remember(user['sub'], fingerprint, storage_id)
|
||||||
|
raise
|
||||||
|
|
||||||
return jsonify({"ok": True})
|
add_wide_event_context(nanoshare={"operation_status": "uploaded", "content_type": content_type})
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
|||||||
@@ -10,15 +10,22 @@ import routes.handeling.errorsAndBots
|
|||||||
from routes import (
|
from routes import (
|
||||||
basic_bp, auth_login_bp,
|
basic_bp, auth_login_bp,
|
||||||
side_main_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
|
# Views for Requests adding the uris
|
||||||
app.register_blueprint(basic_bp)
|
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(side_main_bp)
|
||||||
app.register_blueprint(upload_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__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=WEB_DEBUG, port=5502)
|
app.run(debug=WEB_DEBUG, port=5502)
|
||||||
|
|||||||
Submodule
+1
Submodule servicelink added at 094bdc8c56
@@ -0,0 +1,210 @@
|
|||||||
|
{% extends "base.htm" %}
|
||||||
|
|
||||||
|
{% block title %}NanoShare - Edit file{% endblock %}
|
||||||
|
|
||||||
|
{% block meta %}
|
||||||
|
<meta name="description" content="NanoShare file editing page.">
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.file-edit-page { margin: clamp(16px, 3vw, 32px) auto; }
|
||||||
|
.info-line { margin-top: 12px; color: var(--muted); font-size: .95rem; }
|
||||||
|
.save-row { margin-top: 18px; display: flex; justify-content: space-evenly; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.status-row { margin-top: 8px; text-align: center; }
|
||||||
|
.status { color: var(--muted); font-size: .92rem; }
|
||||||
|
.status.ok { color: #8ee28e; }
|
||||||
|
.status.err { color: #f08f8f; }
|
||||||
|
.btn-danger { border-color: #8f3b3b; color: #ffd4d4; }
|
||||||
|
.btn-danger:hover { background: #4d1f1f; }
|
||||||
|
.meta-table-wrap { margin-top: 10px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||||
|
.meta-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.meta-table th, .meta-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
|
||||||
|
.meta-table tr:last-child th, .meta-table tr:last-child td { border-bottom: 0; }
|
||||||
|
.meta-table th { width: 160px; color: var(--muted); font-weight: 700; }
|
||||||
|
.danger-zone { margin-top: 20px; border: 1px solid #6a2c2c; border-radius: 12px; padding: 12px; background: rgba(120, 32, 32, 0.14); }
|
||||||
|
.danger-zone h2 { margin: 0 0 6px; font-size: 1rem; }
|
||||||
|
.danger-zone p { margin: 0 0 10px; color: var(--muted); font-size: .92rem; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="file-edit-page">
|
||||||
|
<section class="card" aria-labelledby="edit-title">
|
||||||
|
<h1 id="edit-title" class="page-title">Edit file</h1>
|
||||||
|
<p class="subtle">Update filename, note and expiration date.</p>
|
||||||
|
|
||||||
|
<div class="meta-table-wrap" role="region" aria-label="File metadata">
|
||||||
|
<table class="meta-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">URL</th>
|
||||||
|
<td><a href="{{ share_url }}">{{ share_url }}</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">File ID</th>
|
||||||
|
<td><code>{{ file_id }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Uploaded at</th>
|
||||||
|
<td><time class="local-time" data-ts="{{ file.uploaded_at }}"></time></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">File size</th>
|
||||||
|
<td><span class="badge">{{ file.file_size }}</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="fileName" class="label">Filename</label>
|
||||||
|
<input id="fileName" class="input" type="text" maxlength="255" value="{{ file.file_name }}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="note" class="label">Note</label>
|
||||||
|
<input id="note" class="input" type="text" maxlength="500" value="{{ file.note or '' }}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="expiresMode" class="label">Expiration</label>
|
||||||
|
<select id="expiresMode" class="select">
|
||||||
|
<option value="1h">1 hour</option>
|
||||||
|
<option value="24h">24 hours</option>
|
||||||
|
<option value="7d">7 days</option>
|
||||||
|
<option value="30d">30 days</option>
|
||||||
|
<option value="never">Never</option>
|
||||||
|
<option value="custom">Custom date</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field" id="customWrap" style="display:none;">
|
||||||
|
<label for="customExpire" class="label">Custom expiration</label>
|
||||||
|
<input type="datetime-local" id="customExpire" class="datetime" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="save-row">
|
||||||
|
<button id="saveBtn" class="btn" type="button">Save changes</button>
|
||||||
|
<a class="btn btn-ghost" href="{{ url_for('side_main.file_info', file_id=file_id) }}">View info</a>
|
||||||
|
<a class="btn btn-ghost" href="{{ url_for('side_main.files_list') }}">Back to files</a>
|
||||||
|
</div>
|
||||||
|
<div class="status-row"><span id="status" class="status"></span></div>
|
||||||
|
|
||||||
|
<section class="danger-zone" aria-labelledby="danger-title">
|
||||||
|
<h2 id="danger-title">Danger zone</h2>
|
||||||
|
<p>Deleting a file is permanent and cannot be undone.</p>
|
||||||
|
<button id="deleteBtn" class="btn btn-ghost btn-danger" type="button">Delete file</button>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const fileId = '{{ file_id }}';
|
||||||
|
const initialExpires = '{{ file.expires_at }}';
|
||||||
|
const expiresMode = document.getElementById('expiresMode');
|
||||||
|
const customWrap = document.getElementById('customWrap');
|
||||||
|
const customExpire = document.getElementById('customExpire');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return 'Never';
|
||||||
|
const date = new Date(Number.parseInt(String(value), 10));
|
||||||
|
if (Number.isNaN(date.getTime())) return 'Invalid date';
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalInputValue(timestampMs) {
|
||||||
|
const d = new Date(Number.parseInt(String(timestampMs), 10));
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
|
||||||
|
return local.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg, type) {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.className = 'status';
|
||||||
|
if (type) statusEl.classList.add(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncExpireUI() {
|
||||||
|
customWrap.style.display = expiresMode.value === 'custom' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialExpires) {
|
||||||
|
expiresMode.value = 'custom';
|
||||||
|
customExpire.value = toLocalInputValue(initialExpires);
|
||||||
|
} else {
|
||||||
|
expiresMode.value = 'never';
|
||||||
|
}
|
||||||
|
syncExpireUI();
|
||||||
|
expiresMode.addEventListener('change', syncExpireUI);
|
||||||
|
|
||||||
|
document.querySelectorAll('time.local-time').forEach((el) => {
|
||||||
|
el.textContent = formatDate(el.dataset.ts || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('saveBtn').addEventListener('click', async () => {
|
||||||
|
const fileName = document.getElementById('fileName').value.trim();
|
||||||
|
const note = document.getElementById('note').value.trim();
|
||||||
|
|
||||||
|
if (!fileName) {
|
||||||
|
setStatus('Filename is required.', 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expires = expiresMode.value;
|
||||||
|
if (expiresMode.value === 'custom') {
|
||||||
|
if (!customExpire.value) {
|
||||||
|
setStatus('Pick a custom date.', 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expires = new Date(customExpire.value).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Saving...');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/file/${encodeURIComponent(fileId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ file_name: fileName, note, expires }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.ok) {
|
||||||
|
throw new Error(data.error || 'Could not update file');
|
||||||
|
}
|
||||||
|
setStatus('Saved.', 'ok');
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error.message || 'Could not update file.', 'err');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('deleteBtn').addEventListener('click', async () => {
|
||||||
|
const ok = window.confirm('Delete this file? This cannot be undone.');
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
setStatus('Deleting...');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/file/${encodeURIComponent(fileId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.ok) {
|
||||||
|
throw new Error(data.error || 'Could not delete file');
|
||||||
|
}
|
||||||
|
window.location.href = '{{ url_for("side_main.files_list") }}';
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error.message || 'Could not delete file.', 'err');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
{% extends "base.htm" %}
|
||||||
|
|
||||||
|
{% block title %}NanoShare - File info{% endblock %}
|
||||||
|
|
||||||
|
{% block meta %}
|
||||||
|
<meta name="description" content="NanoShare file details and access information.">
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.file-info-page { padding: clamp(16px, 3vw, 28px); }
|
||||||
|
.top-grid { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 16px; align-items: start; }
|
||||||
|
.kv { display: grid; grid-template-columns: 180px 1fr; gap: 10px 14px; margin-top: 14px; }
|
||||||
|
.kv dt { color: var(--muted); font-weight: 700; }
|
||||||
|
.kv dd { margin: 0; word-break: break-word; }
|
||||||
|
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: .92rem; }
|
||||||
|
.preview-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: color-mix(in srgb, var(--panel-2) 60%, transparent); }
|
||||||
|
.preview-head { padding: 10px 12px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: .9rem; font-weight: 700; }
|
||||||
|
.preview-body { min-height: 230px; display: grid; place-items: center; padding: 10px; }
|
||||||
|
.preview-body img, .preview-body video, .preview-body audio, .preview-body iframe { width: 100%; max-width: 100%; border: 0; border-radius: 8px; }
|
||||||
|
.preview-body video { max-height: 260px; }
|
||||||
|
.preview-body pre { width: 100%; max-height: 260px; overflow: auto; margin: 0; padding: 10px; border-radius: 8px; border: 1px solid var(--border); background: rgba(0,0,0,.16); white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.preview-note { color: var(--muted); font-size: .88rem; text-align: center; }
|
||||||
|
@media (max-width: 980px) { .top-grid { grid-template-columns: 1fr; } }
|
||||||
|
@media (max-width: 720px) { .kv { grid-template-columns: 1fr; gap: 4px; } }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="file-info-page">
|
||||||
|
<section class="card" style="padding: clamp(18px, 2.6vw, 28px);">
|
||||||
|
<div class="top-grid">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">File details</h1>
|
||||||
|
<p class="subtle">Everything about this file, including expiration date and recent accesses.</p>
|
||||||
|
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Filename</dt>
|
||||||
|
<dd>{{ file.file_name }}</dd>
|
||||||
|
|
||||||
|
<dt>Note</dt>
|
||||||
|
<dd>{{ file.note or 'No note' }}</dd>
|
||||||
|
|
||||||
|
<dt>Size</dt>
|
||||||
|
<dd><span class="badge">{{ file.file_size }}</span></dd>
|
||||||
|
|
||||||
|
<dt>Uploaded at</dt>
|
||||||
|
<dd><time class="local-time" data-ts="{{ file.uploaded_at }}"></time></dd>
|
||||||
|
|
||||||
|
<dt>Expires at</dt>
|
||||||
|
<dd>
|
||||||
|
{% if file.expires_at %}
|
||||||
|
<time class="local-time" data-ts="{{ file.expires_at }}"></time>
|
||||||
|
{% else %}
|
||||||
|
Never
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Public URL</dt>
|
||||||
|
<dd><a class="mono" href="{{ share_url }}">{{ share_url }}</a></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="preview-card" aria-label="File preview">
|
||||||
|
<div class="preview-head">Preview</div>
|
||||||
|
<div id="previewBody" class="preview-body">
|
||||||
|
<p class="preview-note">Loading preview...</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<a class="btn" href="{{ url_for('side_main.file_edit', file_id=file.file_id) }}">Edit</a>
|
||||||
|
<button id="copyUrlBtn" class="btn btn-ghost" type="button">Copy link</button>
|
||||||
|
<a class="btn btn-ghost" href="{{ url_for('side_main.files_list') }}">Back to files</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" style="padding: clamp(18px, 2.6vw, 28px); margin-top: 16px;">
|
||||||
|
<h2 style="margin:0 0 8px;">Access history</h2>
|
||||||
|
<p class="subtle">Latest request metadata for this file.</p>
|
||||||
|
|
||||||
|
<div class="table-wrap" role="region" aria-label="Access history" tabindex="0">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Time</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">IP</th>
|
||||||
|
<th scope="col">User Agent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% if accesses %}
|
||||||
|
{% for access in accesses %}
|
||||||
|
<tr>
|
||||||
|
<td><time class="local-time" data-ts="{{ access.accessed_at }}"></time></td>
|
||||||
|
<td><span class="badge">{{ access.status }}</span></td>
|
||||||
|
<td>{{ access.ip or '-' }}</td>
|
||||||
|
<td>{{ access.user_agent or '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">No access records yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
const raw = String(value);
|
||||||
|
let date;
|
||||||
|
if (/^\d+$/.test(raw)) {
|
||||||
|
date = new Date(Number.parseInt(raw, 10));
|
||||||
|
} else {
|
||||||
|
date = new Date(raw);
|
||||||
|
}
|
||||||
|
if (Number.isNaN(date.getTime())) return raw;
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('time.local-time').forEach((el) => {
|
||||||
|
const raw = el.dataset.ts || '';
|
||||||
|
el.textContent = formatDate(raw) || '-';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadPreview() {
|
||||||
|
const previewBody = document.getElementById('previewBody');
|
||||||
|
if (!previewBody) return;
|
||||||
|
|
||||||
|
let url = '{{ share_url }}';
|
||||||
|
if (window.location.protocol === 'https:' && url.startsWith('http://')) {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.host === window.location.host) {
|
||||||
|
parsed.protocol = 'https:';
|
||||||
|
url = parsed.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { method: 'GET' });
|
||||||
|
if (!response.ok) {
|
||||||
|
previewBody.innerHTML = '<p class="preview-note">Preview unavailable.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||||
|
|
||||||
|
if (contentType.startsWith('image/')) {
|
||||||
|
previewBody.innerHTML = `<img src="${url}" alt="File preview">`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contentType.startsWith('video/')) {
|
||||||
|
previewBody.innerHTML = `<video controls src="${url}"></video>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contentType.startsWith('audio/')) {
|
||||||
|
previewBody.innerHTML = `<audio controls src="${url}"></audio>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contentType.includes('pdf')) {
|
||||||
|
previewBody.innerHTML = `<iframe src="${url}" title="PDF preview" height="260"></iframe>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contentType.startsWith('text/') || contentType.includes('json') || contentType.includes('xml')) {
|
||||||
|
const text = await response.text();
|
||||||
|
const safe = text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
previewBody.innerHTML = `<pre>${safe.slice(0, 6000) || 'Empty text file.'}</pre>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewBody.innerHTML = `<p class="preview-note">No inline preview for this file type.<br><a href="${url}" target="_blank" rel="noopener">Open file</a></p>`;
|
||||||
|
} catch {
|
||||||
|
previewBody.innerHTML = '<p class="preview-note">Preview unavailable.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPreview();
|
||||||
|
|
||||||
|
document.getElementById('copyUrlBtn')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText('{{ share_url }}');
|
||||||
|
const btn = document.getElementById('copyUrlBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
const label = btn.textContent;
|
||||||
|
btn.textContent = 'Copied';
|
||||||
|
setTimeout(() => { btn.textContent = label; }, 1200);
|
||||||
|
} catch {
|
||||||
|
alert('Could not copy link.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -176,20 +176,30 @@ fileInput.addEventListener('change',e=>{
|
|||||||
});
|
});
|
||||||
|
|
||||||
/* ====== Clipboard handling ====== */
|
/* ====== Clipboard handling ====== */
|
||||||
window.addEventListener('paste',e=>{
|
window.addEventListener('paste', e => {
|
||||||
if(file || (text && text.trim())) return;
|
if (e.target === pasteEl) return; // 👈 let browser handle it
|
||||||
const items=e.clipboardData?.items||[];
|
|
||||||
const fItem=[...items].find(it=>it.kind==='file');
|
if (file || (text && text.trim())) return;
|
||||||
if(fItem){
|
|
||||||
|
const items = e.clipboardData?.items || [];
|
||||||
|
const fItem = [...items].find(it => it.kind === 'file');
|
||||||
|
|
||||||
|
if (fItem) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const blob=fItem.getAsFile();
|
const blob = fItem.getAsFile();
|
||||||
if(blob) setFileFromBlob(blob);
|
if (blob) setFileFromBlob(blob);
|
||||||
return;
|
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 ====== */
|
/* ====== Expiration handling ====== */
|
||||||
expiresMode.addEventListener('change',()=>{
|
expiresMode.addEventListener('change',()=>{
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from quart import Blueprint, Quart, request
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
class FakeLimiter:
|
||||||
|
def exempt(self, func):
|
||||||
|
return func
|
||||||
|
|
||||||
|
def limit(self, *_args, **_kwargs):
|
||||||
|
def decorator(func):
|
||||||
|
return func
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
class FakeLogger:
|
||||||
|
def __init__(self):
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
def error(self, message):
|
||||||
|
self.errors.append(message)
|
||||||
|
|
||||||
|
def warning(self, message):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FailingConvex:
|
||||||
|
async def get_current_favicon(self):
|
||||||
|
raise httpx.ConnectError("[Errno -2] Name or service not known")
|
||||||
|
|
||||||
|
def load_module(module_name, module_path):
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
def install_route_test_modules(monkeypatch, app, logger):
|
||||||
|
fake_setup = types.ModuleType("my_modules.app.setup")
|
||||||
|
fake_setup.app = app
|
||||||
|
fake_setup.LIMITER = FakeLimiter()
|
||||||
|
monkeypatch.setitem(sys.modules, "my_modules.app.setup", fake_setup)
|
||||||
|
|
||||||
|
fake_constens = types.ModuleType("my_modules.app.constens")
|
||||||
|
fake_constens.BLOCKED_IPS_ACCESSING_TIMES = 3
|
||||||
|
fake_constens.BLOCKED_IPS_STORED_TIMEFRAME = 60
|
||||||
|
monkeypatch.setitem(sys.modules, "my_modules.app.constens", fake_constens)
|
||||||
|
|
||||||
|
fake_logger_module = types.ModuleType("my_modules.app.logger")
|
||||||
|
fake_logger_module.logger = logger
|
||||||
|
monkeypatch.setitem(sys.modules, "my_modules.app.logger", fake_logger_module)
|
||||||
|
|
||||||
|
fake_functions = types.ModuleType("my_modules.functions")
|
||||||
|
fake_functions.get_ip = lambda: "203.0.113.10"
|
||||||
|
fake_functions.enforce_custom_limit = lambda *_args, **_kwargs: None
|
||||||
|
fake_functions.get_request_context = lambda: types.SimpleNamespace(path=request.path)
|
||||||
|
fake_functions.is_valid_uuid = lambda value: True
|
||||||
|
monkeypatch.setitem(sys.modules, "my_modules.functions", fake_functions)
|
||||||
|
|
||||||
|
def register_template_routes(app):
|
||||||
|
side_main = Blueprint("side_main", __name__)
|
||||||
|
|
||||||
|
async def index():
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
side_main.add_url_rule("/", "index", index)
|
||||||
|
side_main.add_url_rule("/files", "files_list", index)
|
||||||
|
side_main.add_url_rule("/access", "access_list", index)
|
||||||
|
app.register_blueprint(side_main)
|
||||||
|
|
||||||
|
auth_login = Blueprint("auth_login", __name__)
|
||||||
|
auth_login.add_url_rule("/login", "login", index)
|
||||||
|
auth_login.add_url_rule("/logout", "logout", index)
|
||||||
|
app.register_blueprint(auth_login)
|
||||||
|
|
||||||
|
|
||||||
|
def load_errors_and_basics(monkeypatch, app):
|
||||||
|
logger = FakeLogger()
|
||||||
|
install_route_test_modules(monkeypatch, app, logger)
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
errors = load_module("test_routes_handeling_errorsAndBots", root / "routes" / "handeling" / "errorsAndBots.py")
|
||||||
|
basics = load_module("test_routes_handeling_basics", root / "routes" / "handeling" / "basics.py")
|
||||||
|
return errors, basics, logger
|
||||||
|
|
||||||
|
def test_convex_connect_error_is_returned_as_global_database_error(monkeypatch):
|
||||||
|
async def run_test():
|
||||||
|
app = Quart(__name__, template_folder=str(Path(__file__).resolve().parents[1] / "templates" / "side"))
|
||||||
|
register_template_routes(app)
|
||||||
|
app.convex = FailingConvex()
|
||||||
|
_errors, basics, logger = load_errors_and_basics(monkeypatch, app)
|
||||||
|
app.register_blueprint(basics.basic_bp)
|
||||||
|
|
||||||
|
response = await app.test_client().get("/favicon.ico")
|
||||||
|
|
||||||
|
assert response.status_code == 504
|
||||||
|
assert any("Name or service not known" in str(error) for error in logger.errors)
|
||||||
|
|
||||||
|
asyncio.run(run_test())
|
||||||
|
|
||||||
|
def test_api_convex_connect_error_returns_json_database_error(monkeypatch):
|
||||||
|
async def run_test():
|
||||||
|
app = Quart(__name__, template_folder=str(Path(__file__).resolve().parents[1] / "templates" / "side"))
|
||||||
|
load_errors_and_basics(monkeypatch, app)
|
||||||
|
|
||||||
|
@app.get("/api/failing")
|
||||||
|
async def failing_api():
|
||||||
|
raise httpx.ConnectError("[Errno -2] Name or service not known")
|
||||||
|
|
||||||
|
response = await app.test_client().get("/api/failing")
|
||||||
|
payload = await response.get_json()
|
||||||
|
|
||||||
|
assert response.status_code == 504
|
||||||
|
assert payload["error"] == "Database Error"
|
||||||
|
|
||||||
|
asyncio.run(run_test())
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from quart import Quart
|
||||||
|
|
||||||
|
from servicelink import Request, handle_envelope
|
||||||
|
|
||||||
|
# Load routes/api/link.py in isolation. Importing it as `routes.api.link` would
|
||||||
|
# run routes/__init__.py -> my_modules.app.setup -> constens (asyncio.run at
|
||||||
|
# import), which fails under pytest's output capture. We stub my_modules.app.setup
|
||||||
|
# with a no-op limiter; link.py's other deps (my_modules.expiry/file_meta,
|
||||||
|
# servicelink, quart) are side-effect free.
|
||||||
|
class _NoOpLimiter:
|
||||||
|
def limit(self, *args, **kwargs):
|
||||||
|
return lambda fn: fn
|
||||||
|
|
||||||
|
_setup_stub = types.ModuleType('my_modules.app.setup')
|
||||||
|
_setup_stub.LIMITER = _NoOpLimiter()
|
||||||
|
sys.modules.setdefault('my_modules.app.setup', _setup_stub)
|
||||||
|
|
||||||
|
_LINK_PATH = Path(__file__).resolve().parents[1] / 'routes' / 'api' / 'link.py'
|
||||||
|
_spec = importlib.util.spec_from_file_location('picoshare_link_under_test', _LINK_PATH)
|
||||||
|
link = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(link)
|
||||||
|
|
||||||
|
class FakeConvex:
|
||||||
|
def __init__(self):
|
||||||
|
self.files = {}
|
||||||
|
|
||||||
|
async def decode_access_token_payload(self, access_token, required_scope=None, required_scopes=None):
|
||||||
|
if access_token == 'good':
|
||||||
|
return {'sub': 'user_123', 'scope': 'all'}
|
||||||
|
if access_token == 'nomesh':
|
||||||
|
return {'sub': 'user_123', 'scope': 'files'}
|
||||||
|
return {'error': 'invalid token'}
|
||||||
|
|
||||||
|
async def send_to_storage(self, data, content_type):
|
||||||
|
return 'storage_1'
|
||||||
|
|
||||||
|
async def add_file(self, file_name, file_size, note, content_type, expires_at, storage_id, user_id):
|
||||||
|
self.files[file_name] = {
|
||||||
|
'file_name': file_name,
|
||||||
|
'note': note,
|
||||||
|
'content_type': content_type,
|
||||||
|
'storage_id': storage_id,
|
||||||
|
'user_id': user_id,
|
||||||
|
}
|
||||||
|
return self.files[file_name]
|
||||||
|
|
||||||
|
async def get_file(self, file_id):
|
||||||
|
return self.files.get(file_id)
|
||||||
|
|
||||||
|
def _call(envelope, token=None):
|
||||||
|
# Call the handler the /rpc route delegates to, bypassing the LIMITER/size-cap
|
||||||
|
# wrapper; exercises the real verify (mesh scope) + dispatch + handlers.
|
||||||
|
async def run():
|
||||||
|
app = Quart(__name__)
|
||||||
|
app.convex = FakeConvex()
|
||||||
|
async with app.test_request_context('/rpc', method='POST'):
|
||||||
|
status, body, _ = await handle_envelope(
|
||||||
|
link.router,
|
||||||
|
json.dumps(envelope).encode('utf-8'),
|
||||||
|
authorization=f'Bearer {token}' if token else None,
|
||||||
|
verify=link.verify,
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
return status, json.loads(body)
|
||||||
|
|
||||||
|
return asyncio.run(run())
|
||||||
|
|
||||||
|
def _request(method, params=None):
|
||||||
|
return Request.create(method, params or {}, source='browser-cli', target='picoshare').to_dict()
|
||||||
|
|
||||||
|
def test_text_upload_roundtrip():
|
||||||
|
status, body = _call(_request('files.upload', {'text': 'hello mesh', 'file_name': 'note.txt', 'note': 'scraped'}), token='good')
|
||||||
|
assert status == 200
|
||||||
|
assert body['ok'] is True
|
||||||
|
assert body['result']['file_name'] == 'note.txt'
|
||||||
|
assert body['result']['content_type'] == 'text/plain'
|
||||||
|
assert body['result']['size'] == len('hello mesh')
|
||||||
|
|
||||||
|
def test_upload_requires_content():
|
||||||
|
status, body = _call(_request('files.upload', {'file_name': 'x'}), token='good')
|
||||||
|
assert status == 422
|
||||||
|
assert body['error']['code'] == 'invalid_params'
|
||||||
|
|
||||||
|
def test_get_missing_file_is_not_found():
|
||||||
|
status, body = _call(_request('files.get', {'file_id': 'ghost'}), token='good')
|
||||||
|
assert status == 404
|
||||||
|
assert body['error']['code'] == 'not_found'
|
||||||
|
|
||||||
|
def test_missing_token_is_unauthorized():
|
||||||
|
status, body = _call(_request('files.list'))
|
||||||
|
assert status == 401
|
||||||
|
assert body['error']['code'] == 'unauthorized'
|
||||||
|
|
||||||
|
def test_token_without_mesh_scope_is_forbidden():
|
||||||
|
status, body = _call(_request('files.list'), token='nomesh')
|
||||||
|
assert status == 403
|
||||||
|
assert body['error']['code'] == 'forbidden'
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import asyncio
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from quart import Quart, g, session
|
||||||
|
|
||||||
|
class FakeLogger:
|
||||||
|
def __init__(self):
|
||||||
|
self.records = []
|
||||||
|
|
||||||
|
async def info(self, message):
|
||||||
|
self.records.append(("info", message))
|
||||||
|
|
||||||
|
async def warning(self, message):
|
||||||
|
self.records.append(("warning", message))
|
||||||
|
|
||||||
|
async def error(self, message):
|
||||||
|
self.records.append(("error", message))
|
||||||
|
|
||||||
|
class FakeIPBotManager:
|
||||||
|
def is_client_ip_always_allowed(self, ip):
|
||||||
|
return False
|
||||||
|
|
||||||
|
class FakeSetupApp:
|
||||||
|
def before_request(self, func):
|
||||||
|
return func
|
||||||
|
|
||||||
|
def context_processor(self, func):
|
||||||
|
return func
|
||||||
|
|
||||||
|
class FakeConvex:
|
||||||
|
async def is_ip_address_whitelisted_or_blocked(self, ip_address):
|
||||||
|
return {"whiteliste": False, "blocked": False}
|
||||||
|
|
||||||
|
async def is_path_blocked(self, path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
class FailingConvex:
|
||||||
|
async def is_ip_address_whitelisted_or_blocked(self, ip_address):
|
||||||
|
raise RuntimeError("convex down")
|
||||||
|
|
||||||
|
def load_middleware(monkeypatch, client_ip="203.0.113.10", logger=None):
|
||||||
|
fake_errors = types.ModuleType("routes.handeling.errorsAndBots")
|
||||||
|
|
||||||
|
async def maybe_a_hacker():
|
||||||
|
return "blocked", 418
|
||||||
|
|
||||||
|
fake_errors.maybe_a_hacker = maybe_a_hacker
|
||||||
|
|
||||||
|
fake_constens = types.ModuleType("my_modules.app.constens")
|
||||||
|
fake_constens.THE_IP_BOT_MANAGER = FakeIPBotManager()
|
||||||
|
fake_constens.SKIP_PATH_PREFIXES = ("/static", "/storage")
|
||||||
|
fake_constens.SKIP_PATHS = ("/favicon.ico",)
|
||||||
|
|
||||||
|
fake_logger_module = types.ModuleType("my_modules.app.logger")
|
||||||
|
fake_logger_module.logger = logger or FakeLogger()
|
||||||
|
|
||||||
|
fake_functions = types.ModuleType("my_modules.functions")
|
||||||
|
fake_functions.get_ip = lambda: client_ip
|
||||||
|
|
||||||
|
fake_setup = types.ModuleType("my_modules.app.setup")
|
||||||
|
fake_setup.app = FakeSetupApp()
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, "routes.handeling.errorsAndBots", fake_errors)
|
||||||
|
monkeypatch.setitem(sys.modules, "my_modules.app.constens", fake_constens)
|
||||||
|
monkeypatch.setitem(sys.modules, "my_modules.app.logger", fake_logger_module)
|
||||||
|
monkeypatch.setitem(sys.modules, "my_modules.functions", fake_functions)
|
||||||
|
monkeypatch.setitem(sys.modules, "my_modules.app.setup", fake_setup)
|
||||||
|
sys.modules.pop("my_modules.middleware", None)
|
||||||
|
|
||||||
|
return importlib.import_module("my_modules.middleware")
|
||||||
|
|
||||||
|
def test_middleware_adds_user_and_client_context(monkeypatch):
|
||||||
|
async def run_test():
|
||||||
|
middleware = load_middleware(monkeypatch)
|
||||||
|
monkeypatch.setattr(middleware, "get_ip", lambda: "203.0.113.10")
|
||||||
|
monkeypatch.setattr(middleware, "logger", FakeLogger())
|
||||||
|
|
||||||
|
app = Quart(__name__)
|
||||||
|
app.secret_key = "test-secret"
|
||||||
|
app.convex = FakeConvex()
|
||||||
|
|
||||||
|
async with app.test_request_context("/files"):
|
||||||
|
g.wide_event = {}
|
||||||
|
session["user"] = {"sub": "user_123", "preferred_username": "demo"}
|
||||||
|
session["login_at"] = 1
|
||||||
|
|
||||||
|
result = await middleware.custom_middleware()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert g.wide_event["user"]["id"] == "user_123"
|
||||||
|
assert g.wide_event["user"]["name"] == "demo"
|
||||||
|
assert g.wide_event["user"]["authenticated"] is True
|
||||||
|
assert "authenticated" not in g.wide_event["session"]
|
||||||
|
assert g.wide_event["session"]["permanent"] is True
|
||||||
|
assert g.wide_event["session"]["login_at_unix"] == 1
|
||||||
|
assert g.wide_event["session"]["age_seconds"] >= 0
|
||||||
|
assert g.wide_event["client"]["ip"] == "203.0.113.10"
|
||||||
|
|
||||||
|
asyncio.run(run_test())
|
||||||
|
|
||||||
|
def test_middleware_marks_missing_convex_as_skipped(monkeypatch):
|
||||||
|
async def run_test():
|
||||||
|
middleware = load_middleware(monkeypatch, client_ip="203.0.113.11")
|
||||||
|
monkeypatch.setattr(middleware, "logger", FakeLogger())
|
||||||
|
|
||||||
|
app = Quart(__name__)
|
||||||
|
app.secret_key = "test-secret"
|
||||||
|
|
||||||
|
async with app.test_request_context("/files"):
|
||||||
|
g.wide_event = {}
|
||||||
|
|
||||||
|
result = await middleware.custom_middleware()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert g.wide_event["client"]["ip"] == "203.0.113.11"
|
||||||
|
assert g.wide_event["security"] == {
|
||||||
|
"convex_missing": True,
|
||||||
|
"middleware_skipped": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
asyncio.run(run_test())
|
||||||
|
|
||||||
|
def test_middleware_records_convex_security_failures(monkeypatch):
|
||||||
|
async def run_test():
|
||||||
|
fake_logger = FakeLogger()
|
||||||
|
middleware = load_middleware(monkeypatch, client_ip="203.0.113.12", logger=fake_logger)
|
||||||
|
|
||||||
|
app = Quart(__name__)
|
||||||
|
app.secret_key = "test-secret"
|
||||||
|
app.convex = FailingConvex()
|
||||||
|
|
||||||
|
async with app.test_request_context("/files"):
|
||||||
|
g.wide_event = {}
|
||||||
|
|
||||||
|
result = await middleware.custom_middleware()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert g.wide_event["client"]["ip"] == "203.0.113.12"
|
||||||
|
assert g.wide_event["security"] == {"ip_lookup_failed": True}
|
||||||
|
assert g.wide_event["error"]["type"] == "RuntimeError"
|
||||||
|
assert fake_logger.records == [("error", "[MIDDLEWARE] Convex ip_lookup failed: convex down")]
|
||||||
|
|
||||||
|
asyncio.run(run_test())
|
||||||
Reference in New Issue
Block a user