Compare commits

..

58 Commits

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

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

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

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

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

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

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

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

- Add NanoShare middleware tests covering safe user context, client IP enrichment, missing Convex handling and Convex security lookup failures.
2026-05-13 20:22:43 +02:00
Daniel Dolezal 26536a3cde fix: rewrite SSH submodule URLs to HTTPS in CI
Build and Push Docker Container / build-and-push (push) Successful in 2m7s
2026-05-02 09:39:13 +02:00
daniel156161 42ce022d7b use new shared blueprint function
Build and Push Docker Container / build-and-push (push) Successful in 1m50s
2026-04-15 19:00:16 +02:00
daniel156161 6ead4b8541 update my_helpers submodule
Build and Push Docker Container / build-and-push (push) Successful in 1m29s
2026-04-15 18:49:58 +02:00
daniel156161 0b81d3d803 use login from quard_common 2026-04-15 18:49:17 +02:00
daniel156161 3d8d74785c fix file preview url
Build and Push Docker Container / build-and-push (push) Successful in 1m30s
2026-04-11 11:35:39 +02:00
daniel156161 1c58de68b6 update submodules 2026-04-11 11:35:20 +02:00
daniel156161 c28e4874d9 update my_helpers and remove useless newline into logger
Build and Push Docker Container / build-and-push (push) Successful in 1m23s
2026-04-06 14:25:48 +02:00
daniel156161 680e8dafff allow to strem upload to convex and reuse file id when upload error happend 2026-04-06 14:20:11 +02:00
daniel156161 b170fcfa98 use hypercorn from .venv folder 2026-04-01 22:11:17 +02:00
daniel156161 4773338ccc add convex health metrics route
Build and Push Docker Container / build-and-push (push) Successful in 2m4s
2026-04-01 21:32:28 +02:00
daniel156161 d9b7c88ccf fix login that it not shows 500 when authentik is to slow
Build and Push Docker Container / build-and-push (push) Successful in 1m51s
2026-04-01 21:17:01 +02:00
daniel156161 7b77387182 remove feature_flag_required for edit and info routes and buttons 2026-04-01 21:15:23 +02:00
daniel156161 65951a23ce use correct convex function and change how the edit html gets the file id 2026-04-01 21:05:07 +02:00
daniel156161 5bbc100d83 add info page for files 2026-04-01 20:39:25 +02:00
daniel156161 cf489c9f4a update submodules
Build and Push Docker Container / build-and-push (push) Failing after 11m40s
2026-04-01 19:45:39 +02:00
daniel156161 eeda177182 allow to edit files 2026-04-01 19:45:22 +02:00
daniel156161 0681bd398c not show pages that not exists yet
Build and Push Docker Container / build-and-push (push) Successful in 1m25s
2026-04-01 16:19:56 +02:00
daniel156161 ea4738ad06 use new quart_common functions 2026-04-01 16:17:30 +02:00
daniel156161 51f02ff5c8 add quart common submodule 2026-04-01 14:38:49 +02:00
daniel156161 b311bcae11 updateing my_helpers submodule 2026-04-01 13:44:55 +02:00
daniel156161 063ff3ca58 change convex from runtime to worker pool
Build and Push Docker Container / build-and-push (push) Successful in 1m52s
2026-03-28 13:21:09 +01:00
daniel156161 fc3a0219a0 update requirements 2026-03-28 13:15:40 +01:00
daniel156161 a6befb5aeb update actions to new version:
Build and Push Docker Container / build-and-push (push) Successful in 1m21s
- checkout: v6
- setup-buildx-action: v4
- login-action: v4
- build-push-action: v7
2026-03-10 08:59:17 +01:00
daniel156161 cc79e43b3c add /login and /logout paths to user auth
Build and Push Docker Container / build-and-push (push) Successful in 1m4s
2026-02-26 11:17:16 +01:00
daniel156161 367e9fbdb6 update requirements
Build and Push Docker Container / build-and-push (push) Successful in 1m17s
2026-02-26 10:39:40 +01:00
daniel156161 6137121209 update to get favicon from compontent by using the storage
Build and Push Docker Container / build-and-push (push) Successful in 1m36s
2026-02-16 12:02:52 +01:00
daniel156161 e8e37e8967 update requirements
Build and Push Docker Container / build-and-push (push) Successful in 1m18s
2026-02-11 12:44:20 +01:00
daniel156161 3cd30d3bad update requirements
Build and Push Docker Container / build-and-push (push) Successful in 1m42s
2026-02-09 10:09:06 +01:00
daniel156161 bea2d98fa6 update requirements
Build and Push Docker Container / build-and-push (push) Successful in 2m9s
2026-01-22 10:19:13 +01:00
daniel156161 d82ed3d43a fix adding of pasted text into convex and fix double paste problem
Build and Push Docker Container / build-and-push (push) Successful in 1m40s
2026-01-19 18:52:27 +01:00
daniel156161 e541000347 create Variables for to many 418 errors when they are a hacker or bot to get blocked
Build and Push Docker Container / build-and-push (push) Successful in 2m28s
2026-01-07 00:00:44 +01:00
daniel156161 b7ec488b44 add check if accessed storage file_id is a uuid and when not send them to a 404 error page
Build and Push Docker Container / build-and-push (push) Successful in 1m31s
2026-01-01 10:29:45 +01:00
daniel156161 9b69069367 update my_helpers submodule 2026-01-01 10:26:02 +01:00
daniel156161 6930bb3a61 remove not neaded safe_name and remove import of pathlib
Build and Push Docker Container / build-and-push (push) Successful in 1m20s
2025-12-29 09:17:42 +01:00
daniel156161 715af77a8c add code to increment blocked path access
Build and Push Docker Container / build-and-push (push) Successful in 1m21s
2025-12-29 09:12:52 +01:00
daniel156161 6280299770 add robots txt file
Build and Push Docker Container / build-and-push (push) Successful in 1m22s
2025-12-23 15:24:22 +01:00
daniel156161 a39862b5ab add testing files 2025-12-23 15:21:23 +01:00
daniel156161 ca7fbe9b80 update my_helpers and git ignore 2025-12-23 15:21:00 +01:00
daniel156161 729e7f5fca add protection that shares the data with my webside 2025-12-23 15:20:36 +01:00
daniel156161 0b2d635fd8 remove gel db completly with package
Build and Push Docker Container / build-and-push (push) Successful in 1m58s
2025-12-22 14:11:19 +01:00
daniel156161 e4171e4b73 delete gel db database schema 2025-12-22 14:03:53 +01:00
daniel156161 410e09a980 add working accessed quary and rename fields in html template
Build and Push Docker Container / build-and-push (push) Successful in 1m37s
2025-12-22 14:00:02 +01:00
daniel156161 fefda61c0b use new convex db base class and remove code that is already into the base class
Build and Push Docker Container / build-and-push (push) Successful in 1m21s
2025-12-22 11:43:05 +01:00
daniel156161 cdab1057cc remove pip package
Build and Push Docker Container / build-and-push (push) Successful in 1m38s
2025-12-22 02:04:30 +01:00
daniel156161 d635da039f move database and file storage from edgedb and disk to convex 2025-12-22 02:04:10 +01:00
daniel156161 88d72e3ee1 install convex package 2025-12-21 19:12:04 +01:00
daniel156161 6f0ad8bec2 move EdgeDB into db sub folder 2025-12-21 19:05:59 +01:00
daniel156161 2ea781c293 change gel db behafer to simple_scoping 2025-11-16 15:34:16 +01:00
daniel156161 51062d2e97 use uv python 3.13 debian slim as base image and not check config to use base image python and frozen to not check for updates of packages only lock file
Build and Push Docker Container / build-and-push (push) Successful in 58s
2025-10-31 14:17:43 +01:00
52 changed files with 2643 additions and 1892 deletions
+8 -4
View File
@@ -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
+6 -3
View File
@@ -1,8 +1,11 @@
__pycache__/
uploads/
.venv/ .venv/
__pycache__/
*.pyc *.pyc
*.json
*.edgeql *.edgeql
.env .env
access.log
valkey_data/
redisinsight/
+6
View File
@@ -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
+3 -5
View File
@@ -1,15 +1,13 @@
FROM python:3.14-slim FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# Install app # Install app
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app
# Install dependencies # Install dependencies
RUN uv sync --locked --compile-bytecode 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", "-"]
-60
View File
@@ -1,60 +0,0 @@
module default {
scalar type access_status extending enum<ok, denied, expired, error>;
type IPAddr {
required value: str {
constraint exclusive;
}
}
type UserAgent {
required value: str {
constraint exclusive;
}
}
type files {
required file_id: str {
constraint exclusive;
};
required file_name: str;
required file_size: str;
required note: str;
required content_type: str {
readonly := true;
}
property expires_at: datetime;
required uploaded_at: datetime {
readonly := true;
}
multi accesses -> file_access {
on source delete delete target if orphan;
};
required property user_id: str {
readonly := true;
};
index on ((.file_id, .file_name, .expires_at, .user_id));
}
type file_access {
required link ip -> IPAddr {
readonly := true;
on source delete delete target if orphan;
}
required link user_agent -> UserAgent {
readonly := true;
on source delete delete target if orphan;
}
required status: access_status {
default := access_status.ok;
}
required at: datetime {
readonly := true;
default := datetime_of_statement();
}
}
}
-4
View File
@@ -1,4 +0,0 @@
watch = []
[instance]
server-version = "6.11"
-345
View File
@@ -1,345 +0,0 @@
from my_modules.file_helper_functions import generate_short_id
from my_modules.app.logger import logger
import asyncio, gel
class EdgeDB:
def __init__(self, database:str=None, tls_security:str='insecure', timeout:int=1, max_retrys:int=10):
self.database = database
self.tls_security = tls_security
self.timeout = timeout
self.max_retrys = max_retrys
self.client = None
# Connect Function
async def __aenter__(self):
await self.connect()
return self
async def __aexit__(self, exc_type, exc, tb):
await self.close()
async def connect(self):
self.client = gel.create_async_client(
tls_security=self.tls_security,
database=self.database,
timeout=self.timeout
)
async def close(self):
if self.client:
await self.client.aclose()
# Query Helper Function
async def run_query_with_reconnection(self, function, *args:tuple, **kwargs):
retry_count = 0
while True:
try:
query = args[0].replace('\n ', '').replace(' ', '')
if args[1:]:
await logger.debug(f'{function.__name__} |{query}| {args[1:]}{kwargs}')
else:
await logger.debug(f'{function.__name__} |{query}| {kwargs}')
return await function(*args, **kwargs)
except gel.errors.ClientConnectionFailedError:
await self.connect()
await logger.error(f'Retry to Query Database: {retry_count}, Function: {args}')
if retry_count > self.max_retrys:
break
await asyncio.sleep(0.5)
retry_count += 1
# File Quary Functions
async def get_file(self, file_id:str):
data = await self.run_query_with_reconnection(
self.client.query_single,
"""
select files {
file_name,
content_type,
expires_at,
user_id
}
filter .file_id = <str>$file_id
limit 1
""",
file_id=file_id
)
if data:
return {
"file_name": data.file_name,
"content_type": data.content_type,
"expires_at": data.expires_at,
"user_id": data.user_id
}
return None
async def get_files(self, current_datetime, user_id:str):
data = await self.run_query_with_reconnection(
self.client.query,
"""
select files {
file_id,
file_name,
file_size,
note,
uploaded_at,
expires_at
}
filter
.user_id = <str>$user_id
and (
(.expires_at ?? <datetime>'9999-12-31T00:00:00Z') > <datetime>$now
)
order by .uploaded_at desc
""",
now=current_datetime,
user_id=user_id,
)
return [{
"file_id": i.file_id,
"file_name": i.file_name,
"file_size": i.file_size,
"note": i.note,
"uploaded_at": i.uploaded_at,
"expires_at": i.expires_at if i.expires_at else '',
} for i in data]
async def add_file(self, file_name:str, file_size:str, note:str, content_type:str, uploaded_at, expires_at, user_id:str):
for attempt in range(10):
try:
return await self.run_query_with_reconnection(
self.client.query_single,
"""
insert files {
file_id := <str>$file_id,
file_name := <str>$file_name,
file_size := <str>$file_size,
note := <str>$note,
content_type := <str>$content_type,
uploaded_at := <datetime>$uploaded_at,
expires_at := <optional datetime>$expires_at,
user_id := <str>$user_id
};
""",
file_id=generate_short_id(),
file_name=file_name,
file_size=file_size,
note=note,
content_type=content_type,
uploaded_at=uploaded_at,
expires_at=expires_at,
user_id=user_id,
)
except gel.errors.ConstraintViolationError as e:
await logger.warning(f'file_id collision on attempt {attempt+1}, regenerating…')
continue
raise RuntimeError("Could not allocate unique file_id after multiple retries")
async def update_file(self, file_id:str, file_name:str, note:str, expires_at, user_id:str):
return await self.run_query_with_reconnection(
self.client.query,
"""
update files
filter .file_id = <str>$file_id and .user_id = <str>$user_id
set {
file_name := <str>$file_name,
note := <str>$note,
expires_at := <datetime>$expires_at
};
""",
file_id=file_id,
file_name=file_name,
note=note,
expires_at=expires_at,
user_id=user_id,
)
async def delete_file(self, file_id:str, user_id:str):
await self.run_query_with_reconnection(
self.client.query,
"""
delete files
filter .file_id = <str>$file_id AND .user_id = <str>$user_id
""",
file_id=file_id,
user_id=user_id
)
async def get_expired_files(self:str, current_datetime):
data = await self.run_query_with_reconnection(
self.client.query,
"""
select files {
file_id,
file_name,
expires_at
}
filter .expires_at < <datetime>$now
order by .expires_at asc;
""",
now=current_datetime
)
return [{
"file_id": item.file_id,
"file_name": item.file_name,
"expires_at": item.expires_at
} for item in data]
async def delete_files_by_ids(self, remove_file_ids:list[str]):
if not remove_file_ids:
return
await self.run_query_with_reconnection(
self.client.query,
"""
delete files
filter .file_id in array_unpack(<array<str>>$ids);
""",
ids=remove_file_ids
)
async def get_file_informations(self, file_id:str):
pass
# File Access Quary Functions
async def add_file_access(self, file_id: str, ip_address: str, status: str, user_agent: str, accessed_at):
return await self.run_query_with_reconnection(
self.client.query,
"""
WITH
used_file := (
SELECT files
FILTER .file_id = <str>$file_id
LIMIT 1
),
ip_obj := (
INSERT IPAddr { value := <str>$ip_address }
UNLESS CONFLICT ON .value
ELSE (
SELECT IPAddr
FILTER .value = <str>$ip_address
)
),
ua_obj := (
INSERT UserAgent { value := <str>$user_agent }
UNLESS CONFLICT ON .value
ELSE (
SELECT UserAgent
FILTER .value = <str>$user_agent
)
),
new_file_access := (
INSERT file_access {
at := <datetime>$accessed_at,
status := <access_status>$status,
ip := ip_obj,
user_agent := ua_obj
}
),
_updated_file := (
UPDATE used_file
SET { accesses += (SELECT new_file_access) }
)
SELECT new_file_access {
at,
status,
ip: { value },
user_agent: { value },
};
""",
file_id=file_id,
accessed_at=accessed_at,
ip_address=ip_address,
status=status,
user_agent=str(user_agent),
)
async def get_all_file_access(self):
data = await self.run_query_with_reconnection(
self.client.query,
"""
select file_access {
status,
ip: {
value
},
user_agent: {
value
},
at
} order by .at desc
"""
)
return [{
"status": str(file.status),
"ip": file.ip.value,
"user_agent": file.user_agent.value,
"accessed_at": file.at,
} for file in data]
async def get_all_access_of_user(self, user_id:str):
data = await self.run_query_with_reconnection(
self.client.query,
"""
select files {
file_id,
file_name,
note,
accesses: {
at,
status,
ip: {
value
},
user_agent: {
value
}
}
order by .at desc
}
filter .user_id = <str>$user_id
""",
user_id=user_id
)
return sorted([{
"file_id": file.file_id,
"file_name": file.file_name,
"file_note": file.note,
"status": access.status,
"ip": access.ip.value,
"user_agent": access.user_agent.value,
"accessed_at": access.at,
} for file in data for access in file.accesses], key=lambda x: x["accessed_at"], reverse=True)
async def get_file_access(self, file_id: str):
data = await self.run_query_with_reconnection(
self.client.query_single,
"""
SELECT files {
accesses: {
status,
ip: { value },
user_agent: { value },
at
}
}
FILTER .file_id = <str>$file_id
LIMIT 1
""",
file_id=file_id,
)
if data:
return [{
"status": str(access.status),
"ip": access.ip.value if access.ip else None,
"user_agent": access.user_agent.value if access.user_agent else None,
"accessed_at": access.at,
} for access in data.accesses]
return None
+67
View File
@@ -0,0 +1,67 @@
from redis.asyncio import Redis as aioredis
from collections import defaultdict
import asyncio, time
class OrphanStorageIdRegistry:
def __init__(self, retention_seconds:int=600, redis_client:aioredis=None):
self.retention_seconds = max(60, int(retention_seconds))
self.redis = redis_client
self._lock = asyncio.Lock()
self._store: dict[tuple[str, str], list[tuple[str, float]]] = defaultdict(list)
self._prefix = "upload:orphan:"
def _key(self, user_id:str, fingerprint:str) -> str:
return f"{self._prefix}{user_id}:{fingerprint}"
def _prune_locked(self, now:float):
threshold = now - self.retention_seconds
for key in list(self._store.keys()):
entries = [entry for entry in self._store[key] if entry[1] >= threshold]
if entries:
self._store[key] = entries
else:
self._store.pop(key, None)
async def remember(self, user_id:str, fingerprint:str|None, storage_id:str):
if not fingerprint or not storage_id:
return
if self.redis is not None:
key = self._key(user_id, fingerprint)
pipe = self.redis.pipeline()
pipe.lpush(key, storage_id)
pipe.expire(key, self.retention_seconds)
await pipe.execute()
return
async with self._lock:
now = time.time()
self._prune_locked(now)
self._store[(user_id, fingerprint)].append((storage_id, now))
async def pop_recent(self, user_id:str, fingerprint:str|None) -> str|None:
if not fingerprint:
return None
if self.redis is not None:
key = self._key(user_id, fingerprint)
value = await self.redis.rpop(key)
if value is None:
return None
if isinstance(value, bytes):
return value.decode("utf-8", errors="ignore")
return str(value)
async with self._lock:
self._prune_locked(time.time())
entries = self._store.get((user_id, fingerprint))
if not entries:
return None
storage_id, _ = entries.pop()
if not entries:
self._store.pop((user_id, fingerprint), None)
return storage_id
async def close(self):
if self.redis is not None:
await self.redis.aclose()
+14
View File
@@ -0,0 +1,14 @@
class TheIPManager:
def __init__(self):
self.always_allowed_ips = set()
# Checks
def is_client_ip_always_allowed(self, client_ip:str):
return client_ip in self.always_allowed_ips
# Add
def add_always_allowed_ip(self, client_ip:str):
self.always_allowed_ips.add(client_ip)
def update_always_allowed_ip(self, client_ips:set):
self.always_allowed_ips.update(client_ips)
+9 -3
View File
@@ -1,7 +1,7 @@
from my_modules.TheIPManager import TheIPManager
from my_modules.app.logger import logger from my_modules.app.logger import logger
from dotenv import find_dotenv, load_dotenv, dotenv_values from dotenv import find_dotenv, load_dotenv, dotenv_values
from pathlib import Path
import os, asyncio import os, asyncio
async def read_dot_file(): async def read_dot_file():
@@ -17,5 +17,11 @@ WEB_DEBUG = os.getenv("WEB_DEBUG", False)
SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "USE_ENV_das_ist_ein_geheimer_schlüssel_1") SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "USE_ENV_das_ist_ein_geheimer_schlüssel_1")
API_GROUP = os.getenv("API_GROUP", 'NanoShare') API_GROUP = os.getenv("API_GROUP", 'NanoShare')
UPLOAD_DIR = Path("uploads") THE_IP_BOT_MANAGER = TheIPManager()
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
SKIP_PATH_PREFIXES = ("/static", "/storage")
SKIP_PATHS = ("/favicon.ico",)
# Blocke IPs (Bots, Hackers)
BLOCKED_IPS_ACCESSING_TIMES = int(os.getenv("BLOCKE_IPS_AFTER_ACCESSING_HOWMANY_TIME", 5))
BLOCKED_IPS_STORED_TIMEFRAME = int(os.getenv("BLOCKE_IPS_STORE_KEYS_TIMEFRAME", 3600))
+2 -11
View File
@@ -1,12 +1,3 @@
from aiologger.formatters.base import Formatter from 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="my_webside_and_api", level="DEBUG" if os.getenv("WEB_DEBUG", False) == "true" else "INFO")
logger.handlers = [handler]
+72 -26
View File
@@ -1,8 +1,19 @@
from my_modules.functions import custom_limit_key from my_modules.functions import (
from my_modules.app.constens import SECRET_KEY, UPLOAD_DIR custom_limit_key,
get_my_ip_address,
get_local_ip_addresses,
replace_last_ip_segment,
generate_all_ips,
)
from my_modules.app.constens import SECRET_KEY, THE_IP_BOT_MANAGER
from my_modules.OrphanStorageIdRegistry import OrphanStorageIdRegistry
from my_modules.AsyncCache import AsyncCache from my_modules.AsyncCache import AsyncCache
from my_modules.app.logger import logger from my_modules.app.logger import logger
from my_modules.EdgeDB import EdgeDB from quart_common.web.wide_event import register_wide_event_logging
from my_helpers.db.convex.ConvexRuntime import ConvexRuntime
from my_helpers.db.convex.ConvexWorkerPool import ConvexWorkerPool
from my_modules.db.ConvexDB import ConvexDB
from quart_session import Session from quart_session import Session
from flask_limiter import Limiter from flask_limiter import Limiter
@@ -11,11 +22,14 @@ 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
app.upload_folder = UPLOAD_DIR
# Cache, Sessions and Limiter over Valkey # Cache, Sessions and Limiter over Valkey
if os.getenv("VALKEY_HOST", None) is not None: if os.getenv("VALKEY_HOST", None) is not None:
@@ -26,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(
@@ -35,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(
@@ -58,23 +72,55 @@ 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_edgedb(): async def init_convex():
app.edgedb = EdgeDB( await convex_runtime.start()
database=os.getenv("EDGEDB_DATABASE"), app.convex = ConvexDB(runtime=convex_runtime)
tls_security=None if app.debug else 'insecure'
) THE_IP_BOT_MANAGER.add_always_allowed_ip('127.0.0.1')
await app.edgedb.connect() THE_IP_BOT_MANAGER.add_always_allowed_ip(await get_my_ip_address())
local_docker_host_ip = get_local_ip_addresses()
if local_docker_host_ip:
base_ip = replace_last_ip_segment(local_docker_host_ip, 1)
all_local_ips = generate_all_ips(base_ip)
THE_IP_BOT_MANAGER.update_always_allowed_ip(all_local_ips)
@app.after_serving @app.after_serving
async def close_edgedb(): async def close_convex():
if app.edgedb: if app.convex:
await app.edgedb.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()
+124
View File
@@ -0,0 +1,124 @@
from my_helpers.db.convex.ConvexDbBase import ConvexDbBase
from my_helpers.db.convex.ConvexRuntime import ConvexRuntime
from my_modules.app.logger import logger
from datetime import datetime
class ConvexDB(ConvexDbBase):
service_namespace = 'nanoshare'
def __init__(self, runtime:ConvexRuntime):
super().__init__(
runtime=runtime,
service=ConvexDB.service_namespace
)
# File Quary Functions
async def get_file(self, file_id:str):
data = await self.run_query(
name='files:getByFileId',
args={ 'file_id': file_id }
)
return data
async def get_files(self, user_id:str):
data = await self.run_query(
name='files:getAllNotExpired',
args={ 'user_id': user_id }
)
return [ {
"file_id": x['file_id'],
"file_name": x['file_name'],
"file_size": x['file_size'],
"note": x['note'],
"expires_at": int(x['expires_at']) if x.get('expires_at', None) else '',
"uploaded_at": int(x['uploaded_at']),
} for x in data ]
async def add_file(self, file_name:str, file_size:str, note:str, content_type:str, expires_at:datetime|None, storage_id:str, user_id:str):
args = {
'file_name': file_name, 'file_size': file_size, 'content_type': content_type,
'note': note,
'file_storage_id': storage_id, 'user_id': user_id
}
if expires_at:
args['expires_at'] = expires_at.isoformat()
data = await self.run_mutation(
name='files:addNewFile',
args=args,
)
return data
async def update_file(self, file_id:str, file_name:str, note:str, expires_at:datetime|None, user_id:str):
args = {
'file_id': file_id,
'file_name': file_name,
'note': note,
'user_id': user_id
}
if expires_at:
args['expires_at'] = expires_at.isoformat()
await self.run_mutation(
name='files:updateFile',
args=args,
)
async def delete_file(self, file_id:str, user_id:str):
await self.run_mutation(
name='files:deleteFile',
args={ 'file_id': file_id, 'user_id': user_id }
)
async def get_file_informations(self, file_id:str, user_id:str):
data = await self.run_query(
name='files:getFileByIdAndUser',
args={ 'file_id': file_id, 'user_id': user_id }
)
return data
# File Access Quary Functions
async def add_file_access(self, file_id: str, ip_address:str, status:str, user_agent:str):
data = await self.run_mutation(
name='access:addNewAccess',
args={ 'file_id': file_id, 'ip_address': ip_address, 'user_agent': str(user_agent), 'status': status }
)
return data
async def get_all_access(self, user_id:str):
data = await self.run_query(
name='access:getAllByUser',
args={ 'user_id': user_id }
)
return data
async def get_file_access(self, file_id:str, user_id:str):
return []
data = await self.run_query_with_reconnection(
self.client.query_single,
"""
SELECT files {
accesses: {
status,
ip: { value },
user_agent: { value },
at
}
}
FILTER .file_id = <str>$file_id
LIMIT 1
""",
file_id=file_id,
)
if data:
return [{
"status": str(access.status),
"ip": access.ip.value if access.ip else None,
"user_agent": access.user_agent.value if access.user_agent else None,
"accessed_at": access.at,
} for access in data.accesses]
return None
+22 -145
View File
@@ -1,150 +1,27 @@
from my_modules.app.constens import SECRET_KEY from my_modules.app.constens import THE_IP_BOT_MANAGER
from my_modules.app.logger import logger from my_modules.app.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, jwt
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
# Custom decorator for token validation
def token_required(func):
@wraps(func)
async def wrapper(*args, **kwargs):
token = await get_auth_token()
if not token:
await logger.error('API Token is missing')
return jsonify(error='Token is missing'), 400
try:
decoded_payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
if not await current_app.edgedb.check_if_refresh_token_exists_by_id(decoded_payload['refresh_id']):
await logger.error(f'API Refresh Token not found: {decoded_payload['refresh_id']}')
return jsonify(error='Refresh Token not found', msg='Please login again', url=url_for('login')), 403
except jwt.ExpiredSignatureError:
await logger.error('API Token has expired')
return jsonify(error='Token has expired'), 401
except jwt.InvalidTokenError:
await logger.error('API Token is invalid')
return jsonify(error='Token is invalid'), 401
return await func(user=decoded_payload, *args, **kwargs)
return wrapper
# Custom decorator for content type reading, convertig dict to response
def parse_request_data(func):
@wraps(func)
async def wrapper(*args, **kwargs):
content_type = request.headers.get('Content-Type', '').lower()
data = None
body = await request.body
if body:
if 'application/msgpack' in content_type or 'application/x-msgpack' in content_type:
try:
data = await asyncio.to_thread(msgpack.unpackb, body, raw=False)
except Exception:
return jsonify({'error': 'Invalid MessagePack'}), 400
elif 'application/json' in content_type:
data = await request.get_json(silent=True)
if data is None:
return jsonify({'error': 'Invalid JSON'}), 400
else:
if request.method in ['POST', 'PUT', 'PATCH', 'DELETE']:
return jsonify({'error': 'Unsupported Content-Type'}), 415
# else:
# if request.method in ['POST', 'PUT', 'PATCH']:
# return jsonify({'error': 'Empty request body'}), 400
return await func(data=data, *args, **kwargs)
return wrapper
def format_response(func):
@wraps(func)
async def wrapper(*args, **kwargs):
result = await func(*args, **kwargs)
# Unpack result: (data), (data, status), (data, headers), (data, status, headers)
data = None
status = 200
headers = {}
if isinstance(result, tuple):
data = result[0]
if len(result) == 2:
if isinstance(result[1], dict):
headers = result[1]
else:
status = result[1]
elif len(result) == 3:
status = result[1]
headers = result[2]
else:
data = result
accept = request.headers.get('Accept', '').lower()
if 'application/msgpack' in accept or 'application/x-msgpack' in accept:
packed = await asyncio.to_thread(msgpack.packb, data, default=encode_object_default, use_bin_type=True)
return Response(packed, content_type='application/msgpack', status=status, headers=headers)
else:
json_str = await asyncio.to_thread(json.dumps, data, ensure_ascii=False, default=encode_object_default)
response = Response(json_str, status=status, content_type='application/json')
response.headers.update(headers)
return response
return wrapper
# Custom decorator for adding limits for spezific methodes by endpoint
def apply_limit(endpoint_name, limits:dict=None):
def make_key_func(endpoint):
def key_func():
ip = get_ip()
# if THE_IP_BOT_MANAGER.is_client_ip_always_allowed(ip):
# return None # No key, no increment, no enforcement
# Combine endpoint name and HTTP method (and client IP) into the rate-limit key
return f":{ip}:{endpoint}:{request.method}:"
return key_func
def decorator(func):
@wraps(func)
async def wrapped(*args, **kwargs):
return await func(*args, **kwargs)
rules = limits.get(endpoint_name)
def dynamic_limit():
if isinstance(rules, dict):
return rules.get(request.method.upper(), "10000 per second")
return rules or "10000 per second"
key_fn = make_key_func(endpoint_name)
return LIMITER.limit(dynamic_limit, key_func=key_fn)(wrapped)
return decorator
# Check if User is loggedin
def login_required(func):
@wraps(func)
async def decorated_function(*args, **kwargs):
user_session = session.get('user')
if user_session is None:
abort(401)
return await func(user=user_session, *args, **kwargs)
return decorated_function
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
import re
PRESET_H = re.compile(r"^(\d+)h$")
PRESET_D = re.compile(r"^(\d+)d$")
def parse_expires(value: str | None) -> datetime | None:
"""Parse expiration presets or ISO datetime."""
if not value or value == "never":
return None
value = value.strip()
if not value:
return None
if m := PRESET_H.match(value):
return datetime.now(timezone.utc) + timedelta(hours=int(m.group(1)))
if m := PRESET_D.match(value):
return datetime.now(timezone.utc) + timedelta(days=int(m.group(1)))
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
except Exception:
return None
def ensure_utc(dt:datetime):
"""Ensure a timezone-aware UTC datetime or None."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
-45
View File
@@ -1,45 +0,0 @@
from my_modules.app.constens import SECRET_KEY
import hmac, hashlib, base64, secrets, string, time
from datetime import datetime, timezone
def base64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode().rstrip("=")
def base64url_decode(data: str) -> bytes:
padding = '=' * (-len(data) % 4)
return base64.urlsafe_b64decode(data + padding)
def generate_short_id(length=11):
alphabet = string.ascii_letters + string.digits + '_-'
while True:
token = ''.join(secrets.choice(alphabet) for _ in range(length))
if not token.startswith('-'):
return token
def generate_signed_url(file_id: str) -> str:
# signature based only on the file_id
sig = hmac.new(SECRET_KEY, file_id.encode(), hashlib.sha256).digest()
token = base64url_encode(sig)
return f"-{file_id}?sig={token}"
def verify_signed_url(file_id: str, token: str, file_expiration: int) -> bool:
# check both the signature and the file's stored expiration time
expected_sig = hmac.new(SECRET_KEY, file_id.encode(), hashlib.sha256).digest()
valid_sig = hmac.compare_digest(base64url_encode(expected_sig), token)
not_expired = file_expiration >= time.time()
return valid_sig and not_expired
def is_expired(expires_at):
if not expires_at:
return False
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
else:
expires_at = expires_at.astimezone(timezone.utc)
return expires_at <= datetime.now(timezone.utc)
if __name__ == "__main__":
file_id = generate_short_id()
url = generate_signed_url(file_id)
print(url)
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime, timezone
def iso_stamp_filename(prefix: str, ext: str) -> str:
"""Generate timestamped filename, e.g. pasted-2025-10-23T121212Z.png"""
ts = datetime.now(timezone.utc).isoformat()
ts = ts.replace(":", "").split(".")[0]
if ts.endswith("+00:00"):
ts = ts.replace("+00:00", "Z")
return f"{prefix}-{ts}.{ext}"
def format_size(num_bytes: int) -> str:
"""Return a human-readable file size (e.g., '2.3 MB', '10 Bytes')."""
if num_bytes < 1024:
return f"{num_bytes} Byte{'s' if num_bytes != 1 else ''}"
units = ["KB", "MB", "GB", "TB", "PB", "EB"]
size = float(num_bytes)
for unit in units:
size /= 1024.0
if size < 1024.0 or unit == units[-1]:
# 1 decimal place; drop trailing .0 (optional)
val = f"{size:.1f}"
if val.endswith(".0"):
val = val[:-2]
return f"{val} {unit}"
return f"{num_bytes} Bytes" # fallback
+11 -43
View File
@@ -1,39 +1,15 @@
from quart import has_request_context, request, has_websocket_context, websocket from quart_common.web.request import (
get_ip,
get_my_ip_address,
get_local_ip_addresses,
generate_all_ips,
replace_last_ip_segment,
get_request_context,
is_valid_uuid,
)
from quart_common.web.env import is_development_environment, is_testing_environment
from flask_limiter import Limiter from flask_limiter import Limiter
import subprocess, aiohttp
# Get IPs
def get_ip():
if has_request_context():
xff = request.headers.get("X-Forwarded-For", "")
return xff.split(",")[0].strip() if xff else request.remote_addr
elif has_websocket_context():
xff = websocket.headers.get("X-Forwarded-For", "")
return xff.split(",")[0].strip() if xff else websocket.remote_addr
return None # No active request or websocket context
async def get_my_ip_address():
async with aiohttp.ClientSession() as session:
async with session.get("https://ipinfo.io/ip") as response:
if response.status == 200:
return await response.text()
raise aiohttp.ClientError(f'Could not get IP: {response.status} {await response.text()}')
def get_local_ip_addresses():
try:
result = subprocess.run(['hostname', '-I'], capture_output=True, text=True)
first_ip = result.stdout.strip().split()[0]
return first_ip
except subprocess.CalledProcessError as e:
return None
except IndexError:
return None
def generate_all_ips(base_ip:str) -> set:
ips = set()
for i in range(1, 255): # 1 to 254 inclusive
ips.add(replace_last_ip_segment(base_ip, i))
return ips
# Limiter Key Gen # Limiter Key Gen
def custom_limit_key(): def custom_limit_key():
@@ -53,11 +29,3 @@ def enforce_custom_limit(limiter:Limiter, key:str, limit_count: int = 3, window_
current = limiter.storage.incr(key, expiry=window_sec) 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")
+14 -18
View File
@@ -1,27 +1,23 @@
from routes.handeling.errorsAndBots import maybe_a_hacker
from my_modules.app.constens import THE_IP_BOT_MANAGER, SKIP_PATH_PREFIXES, SKIP_PATHS
from my_modules.app.logger import logger from my_modules.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,
)
# Skip allowed IPs or non-critical assets
if (
"favicon" in path
or "static" in path
):
return
await logger.info(f"{method} | {client_ip} had accessed the Side {path}")
@app.context_processor @app.context_processor
async def inject_context_data(): async def inject_context_data():
+15 -53
View File
@@ -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", "convex==0.7.0",
"anyio==4.11.0", "flask-limiter==4.1.1",
"attrs==25.4.0",
"authlib==1.6.5",
"blinker==1.9.0",
"certifi==2025.10.5",
"cffi==2.0.0",
"click==8.3.0",
"cryptography==46.0.3",
"deprecated==1.2.18",
"dotenv==0.9.9",
"flask==3.1.2",
"flask-limiter==4.0.0",
"frozenlist==1.8.0",
"gel==3.1.0",
"h11==0.16.0",
"h2==4.3.0",
"hpack==4.1.0",
"httpcore==1.0.9",
"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
+30 -31
View File
@@ -1,56 +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
cryptography==46.0.3 convex==0.7.0
Deprecated==1.2.18 cryptography==46.0.6
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
View File
@@ -1,6 +1,23 @@
from my_modules.app.setup import cache, LIMITER
from my_modules.app.constens import API_GROUP
from my_modules.app.logger import logger
from .handeling.errorsAndBots import auth_error
from .handeling.basics import basic_bp from .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
+31
View File
@@ -0,0 +1,31 @@
from my_modules.decoratory.header import format_response, feature_flag_required
from my_modules.app.setup import LIMITER
from my_modules.app.logger import logger
from quart_common.web.wide_event import add_wide_event_context
from quart import Blueprint, current_app
health_bp = Blueprint("health", __name__)
@health_bp.route("/convex", methods=["GET"])
@LIMITER.limit("30 per minute")
@feature_flag_required("convex_health", fallback=False, status_code=404)
@format_response
async def get_convex_health_for_api():
add_wide_event_context(health={"check": "convex"})
runtime = getattr(current_app, "convex_runtime", None)
if runtime is None:
add_wide_event_context(health={"status": "unavailable", "reason": "runtime_missing"})
return {
"status": "unavailable",
"description": "Convex runtime is not attached to app",
}, 503
is_alive = await runtime.is_alive()
metrics = runtime.get_metrics()
add_wide_event_context(health={"status": "ok" if is_alive else "unhealthy"})
return {
"alive": is_alive,
"metrics": metrics,
}, 200 if is_alive else 503
+111
View File
@@ -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)
-139
View File
@@ -1,139 +0,0 @@
from my_modules.app.setup import cache, LIMITER
from my_modules.app.constens import API_GROUP
from my_modules.app.logger import logger
from quart import Blueprint, redirect, url_for, session, request, render_template, abort, make_response, current_app
from authlib.jose import JsonWebKey, JoseError, jwt as authlib_jwt
from authlib.integrations.httpx_client import AsyncOAuth2Client
from jwt import InvalidTokenError
import httpx, uuid, os
auth_login_bp = Blueprint('auth_login', __name__)
# OAuth
OIDC_CLIENT_ID = os.getenv('OIDC_CLIENT_ID', '')
OIDC_CLIENT_SECRET = os.getenv('OIDC_CLIENT_SECRET', '')
OIDC_METADATA_URL = os.getenv('OIDC_METADATA_URL', '')
REDIRECT_URI_SCHEME = os.getenv('REDIRECT_URI_SCHEME', 'http')
async def get_oidc_metadata():
async with httpx.AsyncClient() as client:
response = await client.get(OIDC_METADATA_URL)
response.raise_for_status()
return response.json()
@auth_login_bp.route('/', methods=['GET'])
@LIMITER.limit("5 per minute; 30 per hour")
async def login():
metadata = await get_oidc_metadata()
auth_id = request.cookies.get('auth_id') or str(uuid.uuid4())
nonce = str(uuid.uuid4())
await cache.set(f"oauth:nonce:{auth_id}", nonce, ttl=3603)
client = AsyncOAuth2Client(
client_id=OIDC_CLIENT_ID,
client_secret=OIDC_CLIENT_SECRET,
redirect_uri=url_for('auth_login.auth_callback', _external=True, _scheme=REDIRECT_URI_SCHEME),
scope='openid profile email',
)
uri, state = client.create_authorization_url(
metadata['authorization_endpoint'],
nonce=nonce,
state=auth_id,
)
response = await make_response(redirect(uri))
response.set_cookie('auth_id', auth_id, max_age=3600, httponly=True, secure=True, samesite='Lax')
return response
@auth_login_bp.route('/logout', methods=['GET'])
@LIMITER.limit("10 per minute")
async def logout():
user = session.get('user')
if user:
await logger.info(f'logging out user: {user}')
session.pop('user', None)
session.clear()
return redirect(url_for('side_main.index'))
@auth_login_bp.route('/callback', methods=['GET'])
@LIMITER.limit("5 per minute")
async def auth_callback():
try:
auth_id = request.args.get('state')
code = request.args.get('code')
nonce = await cache.get(f"oauth:nonce:{auth_id}")
if not nonce:
await logger.error('Nonce not found')
return await render_template('views/api/token.htm', error='Nonce not found'), 400
await cache.delete(f"oauth:nonce:{auth_id}")
metadata = await get_oidc_metadata()
client = AsyncOAuth2Client(
client_id=OIDC_CLIENT_ID,
client_secret=OIDC_CLIENT_SECRET,
redirect_uri=url_for('auth_login.auth_callback', _external=True, _scheme=REDIRECT_URI_SCHEME),
scope='openid profile email',
)
# Exchange code for token
token = await client.fetch_token(
metadata['token_endpoint'],
code=code,
grant_type='authorization_code'
)
await logger.debug(f'Auth Callback | token: {token}')
# Decode ID token
id_token = token.get('id_token')
if not id_token:
await logger.error('ID token missing in OAuth response')
return await render_template('views/api/token.htm', error='ID token missing'), 400
# Fetch the JWKs to verify signature
async with httpx.AsyncClient() as http_client:
jwks_resp = await http_client.get(metadata['jwks_uri'])
jwks = jwks_resp.json()
keys = JsonWebKey.import_key_set(jwks)
claims = authlib_jwt.decode(id_token, key=keys)
try:
claims.validate_aud()
claims.validate()
except JoseError as e:
await logger.error(f"JWT validation error: {e}")
return await render_template('views/api/token.htm', error=str(e)), 400
if claims.get('nonce') != nonce:
await logger.error('Nonce mismatch in ID token')
return await render_template('views/api/token.htm', error='Invalid nonce'), 400
await logger.info(f'Auth Callback | user_info: {claims}')
if API_GROUP not in claims.get('groups', []):
await logger.error("You don't have Permissions to Access this API")
return await render_template('views/api/token.htm', error="You don't have Permissions to Access this API"), 403
session['user'] = claims
response = await make_response(redirect(url_for('side_main.index')))
response.set_cookie('auth_id', '', max_age=0, httponly=True, secure=True, samesite='Lax')
return response
except httpx.HTTPError as e:
await logger.error(f"HTTP error during token exchange: {e}")
return await render_template('views/api/token.htm', error="Token exchange failed"), 500
except InvalidTokenError as e:
await logger.error(f"Invalid ID Token: {e}")
return await render_template('views/api/token.htm', error="Invalid ID Token"), 400
except Exception as e:
await logger.exception("Unknown error during OAuth callback")
return await render_template('views/api/token.htm', error=str(e)), 500
+35 -29
View File
@@ -1,40 +1,46 @@
from my_modules.app.setup import LIMITER, cache from my_modules.app.setup import LIMITER
from my_modules.functions import is_valid_uuid
from quart import Blueprint, send_from_directory, render_template, current_app from urllib.parse import urlencode
from datetime import datetime
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(cache_key:str='favicon'): async def favicon():
cache_favicon_name = await cache.get(cache_key) file_data = await current_app.convex.get_current_favicon()
if cache_favicon_name: return redirect(file_data['file_id'])
file_name = cache_favicon_name
else:
current_year = datetime.now().year
autumn_start = datetime(current_year, 9, 23)
autumn_end = datetime(current_year, 12, 21)
winter_start = datetime(current_year, 12, 21)
winter_end = datetime(current_year, 3, 20)
# Get the current date
current_date = datetime.now()
if autumn_start <= current_date <= autumn_end:
file_name = '1. autumn.gif'
elif current_date >= winter_start or current_date <= winter_end:
file_name = '2. winter.png'
else:
file_name = '0. default.svg'
await cache.set(cache_key, file_name, ttl=21600)
return await send_from_directory(current_app.static_folder, f'images/favicons/{file_name}')
@basic_bp.route('/robots.txt', methods=['GET']) @basic_bp.route('/robots.txt', methods=['GET'])
@LIMITER.limit('3 per day') @LIMITER.limit('3 per day')
async def robots(): async def robots():
return await send_from_directory(current_app.static_folder, f'robots.txt') return await send_from_directory(current_app.static_folder, f'robots.txt')
@basic_bp.route("/storage/<path:file_id>")
async def convex_storage_proxy(file_id:str):
if not is_valid_uuid(file_id):
return abort(404, "Not a valid uuid")
query_keys = set(request.args.keys())
if query_keys - {"component"}:
return abort(400, "Only the component query parameter is allowed")
component_values = request.args.getlist("component")
if len(component_values) > 1:
return abort(400, "Only one component query parameter is allowed")
storage_file_id = file_id
if component_values:
storage_file_id = f"{file_id}?{urlencode({'component': component_values[0]})}"
stream, headers = await current_app.convex.open_storage_stream(storage_file_id, add_api_path=True)
if 'Content-Type' not in headers:
headers['Content-Type'] = 'application/octet-stream'
return Response(stream, headers=headers)
+256 -17
View File
@@ -1,18 +1,141 @@
from my_modules.app.constens import (
BLOCKED_IPS_ACCESSING_TIMES,
BLOCKED_IPS_STORED_TIMEFRAME,
)
from my_modules.app.setup import app, LIMITER from my_modules.app.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, redirect, url_for 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):
try: add_wide_event_context(auth={"operation_status": "unauthorized"}, error={"type": type(e).__name__, "message": str(e)})
enforce_custom_limit(LIMITER, "401", limit_count=5, window_sec=1800) context = get_request_context()
except LookupError as e: if context.path.startswith("/api"):
return await to_many_requests(e) 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 redirect(url_for('auth_login.login')) return await render_template('views/basics/error.htm',
title='Unauthorized Access',
header={'title': '401 - Unauthorized', 'message': "Gandalf has spoken: You shall not pass… until you log in."},
file={'name': '401.gif', 'alt': "Gandalf blocking the bridge You shall not pass!"},
), 401
@app.errorhandler(404) @app.errorhandler(404)
async def not_found(e): async def not_found(e):
@@ -21,18 +144,70 @@ 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)
await logger.error(f"[404] Page Not Found: {request.path}") context = get_request_context()
error_description = str(getattr(e, "description", ""))
is_feature_flag_disabled_404 = error_description.startswith(FEATURE_FLAG_DISABLED_PREFIX)
if (
not is_development_environment()
and not is_feature_flag_disabled_404
and context.path not in IGNORED_404_PATHS
and not any(p in context.path for p in IGNORE_CONTAIN_404_PATHS)
):
await _safe_convex_error_tracking(
"404_increment",
current_app.convex.increment_page_not_found_error(path=context.path, status=404),
)
add_wide_event_context(error={"type": "NotFound", "message": str(e)})
await log_when_wide_event_disabled(logger, "error", f"[404] Page Not Found: {context.path}")
if context.path.startswith("/api"):
return jsonify({"error": "Page Not Found", "message": "Oops! The page you are looking for does not exist."}), 404
return await render_template('views/basics/error.htm', return await render_template('views/basics/error.htm',
title='Page Not Found', title='Page Not Found',
header={'title': '404 - Page Not Found', 'message': "Oops! The page you are looking for does not exist."}, header={'title': '404 - Page Not Found', 'message': "Oops! The page you are looking for does not exist."},
file={'name': '404.webp', 'alt': "Matrix - Neo stoping the Bullets by holding his hand up"}, file={'name': '404.webp', 'alt': "Matrix - Neo stoping the Bullets by holding his hand up"},
), 404 ), 404
@app.errorhandler(418)
async def maybe_a_hacker(e=None):
add_wide_event_context(security={"blocked": True, "block_reason": "honeypot"})
try:
enforce_custom_limit(LIMITER, "BotScan", BLOCKED_IPS_ACCESSING_TIMES, BLOCKED_IPS_STORED_TIMEFRAME)
except LookupError as e:
client_ip=get_ip()
await _safe_convex_error_tracking(
"honeypot_ip_increment",
current_app.convex.increment_blocked_ip_address_access(
ip_address=client_ip,
method=request.method,
path=request.path,
),
)
await log_when_wide_event_disabled(logger, "warning", f"[HONEYPOT] Blocked {client_ip} after accessing {request.path}")
return await to_many_requests(e)
rendered = await render_template('views/basics/error.htm',
title='Oops! Something Went AWOL!',
header={'title': "418 - I'm a Teapot", 'message': f"You don't say the Magic Word. By the way, we might have your IP now, but dont worry, it's in safe hands (probably). Feel free to keep poking around, just maybe give us a sec to catch our breath."},
file={'name': 'hacker_crap.webp', 'alt': "Someone got Hacked and he says I hate this Hacker crap - Jurassic Park Movie"},
)
response = await make_response((rendered, 418))
response.headers['X-Honeypot-Triggered'] = 'true'
response.headers['X-Reason'] = 'Unauthorized access attempt'
return response
@app.errorhandler(429) @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',
@@ -41,30 +216,94 @@ async def to_many_requests(e):
file={'name': '429_JimCarrey.gif', 'alt': "Jim Carrey Tips very fast on a computer keyboard"}, 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)
await logger.error(e) context = get_request_context()
if context.path.startswith("/api"):
return jsonify({"error": "Internal Server Error", "message": "It looks like you broke something... but don't worry, we're fixing it! In the meantime, we may or may not have logged your IP address (just kidding... or are we?). Either way, thanks for helping us find new ways to crash our system. Stay curious, hacker-friend!"}), 500
await log_when_wide_event_disabled(logger, "error", e)
return await render_template('views/basics/error.htm', 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'
message = "It looks like something is broke on our end... but don't worry, we're fixing it! Either way, thanks for helping us find new ways to crash our system. Stay curious, hacker-friend!"
await log_when_wide_event_disabled(logger, "error", e)
context = get_request_context()
if context.path.startswith("/api"):
response = await make_response(jsonify({"error": "Database Error", "message": message}), status_code)
else:
rendered = await render_template('views/basics/error.htm',
title='Database Error', title='Database Error',
header={'title': '504 - Database Error', 'message': "It looks like something is broke on our end... but don't worry, we're fixing it! Either way, thanks for helping us find new ways to crash our system. Stay curious, hacker-friend!"}, header={'title': title, 'message': message},
file={'name': '504.gif', 'alt': "Hex Code running over a screen and ends with Error"}, file={'name': '504.gif', 'alt': "Hex Code running over a screen and ends with Error"},
), 504 )
response = await make_response(rendered, status_code)
response.headers['Retry-After'] = retry_after
return response
+116 -23
View File
@@ -1,13 +1,35 @@
from my_modules.file_helper_functions import is_expired, verify_signed_url
from my_modules.decoratory.header import login_required 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_from_directory, render_template, abort, current_app from quart import (
from datetime import datetime, timezone 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")
@@ -19,43 +41,113 @@ 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):
access_data = await current_app.edgedb.get_all_access_of_user(user_id=user['sub']) add_wide_event_context(nanoshare={"operation": "access_list"})
access_data = await current_app.convex.get_all_access(user_id=user['sub'])
return await render_template("views/webpage/access/list.htm", access_logs=access_data) 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):
files_data = await current_app.edgedb.get_files(current_datetime=datetime.now(timezone.utc), user_id=user['sub']) add_wide_event_context(nanoshare={"operation": "files_list"})
return await render_template("views/webpage/files/list.htm", files=files_data) files_data = await current_app.convex.get_files(user_id=user['sub'])
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.edgedb.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.edgedb.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):
file_data = await current_app.edgedb.get_file(file_id=file_id) add_wide_event_context(nanoshare={"operation": "serve_file", "file_id": file_id})
file_data = await current_app.convex.get_file(file_id=file_id)
disable_logging = False 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')
if user and user['sub'] == file_data['user_id']: if user and user['sub'] == file_data['user_id']:
disable_logging = True disable_logging = True
if is_expired(file_data.get("expires_at")): 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.edgedb.add_file_access(file_id=file_id, ip_address=get_ip(), user_agent=request.user_agent, status="expired", accessed_at=datetime.now(timezone.utc)) 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={
"Cache-Control": "no-store", "Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
@@ -66,21 +158,22 @@ 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"}
path = current_app.upload_folder / file_name if not file_data.get('db_image_url', None):
if not path.exists() or not path.is_file(): add_wide_event_context(nanoshare={"operation_status": "missing_storage", "owner_request": disable_logging})
if not disable_logging: if not disable_logging:
await current_app.edgedb.add_file_access(file_id=file_id, ip_address=get_ip(), user_agent=request.user_agent, status="error", accessed_at=datetime.now(timezone.utc)) 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.edgedb.add_file_access(file_id=file_id, ip_address=get_ip(), user_agent=request.user_agent, status="ok", accessed_at=datetime.now(timezone.utc)) await current_app.convex.add_file_access(file_id=file_id, ip_address=get_ip(), user_agent=request.user_agent, status="ok")
return await send_from_directory(
directory=current_app.upload_folder, return await send_file(
file_name=file_name, filename_or_io=await current_app.convex.get_from_storage(file_data.get('db_image_url')),
mimetype=content_type, mimetype=content_type,
as_attachment=force_download, as_attachment=force_download,
attachment_filename=file_name, attachment_filename=file_name,
conditional=True, conditional=True,
cache_timeout=60, cache_timeout=60,
last_modified=path.stat().st_mtime last_modified=int(file_data['uploaded_at']) / 1000
) )
+124 -143
View File
@@ -1,65 +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
from pathlib import Path
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 safe_name(name: str) -> str:
"""Restrict filename to safe ASCII subset."""
return re.sub(r"[^A-Za-z0-9._-]", "_", name)
def parse_expires(value: str | None) -> datetime | None:
"""Parse expiration presets or ISO datetime."""
if not value:
return None
value = value.strip()
if m := PRESET_H.match(value):
return datetime.now(timezone.utc) + timedelta(hours=int(m.group(1)))
if m := PRESET_D.match(value):
return datetime.now(timezone.utc) + timedelta(days=int(m.group(1)))
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
except Exception:
return None
def format_size(num_bytes: int) -> str:
"""Return a human-readable file size (e.g., '2.3 MB', '10 Bytes')."""
if num_bytes < 1024:
return f"{num_bytes} Byte{'s' if num_bytes != 1 else ''}"
units = ["KB", "MB", "GB", "TB", "PB", "EB"]
size = float(num_bytes)
for unit in units:
size /= 1024.0
if size < 1024.0 or unit == units[-1]:
# 1 decimal place; drop trailing .0 (optional)
val = f"{size:.1f}"
if val.endswith(".0"):
val = val[:-2]
return f"{val} {unit}"
return f"{num_bytes} Bytes" # fallback
async def read_all(uploaded) -> bytes: 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()
@@ -68,17 +23,41 @@ async def read_all(uploaded) -> bytes:
return await data return await data
return data return data
def ensure_utc(dt):
"""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):
""" """
@@ -90,127 +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)
fname = safe_name(fname) stream = getattr(uploaded, 'stream', None)
path = current_app.upload_folder / fname
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) data = await read_all(uploaded)
fingerprint = fingerprint_bytes(data)
# write to disk storage_id = (
async with aiofiles.open(path, "wb") as f: await orphan_registry.pop_recent(user['sub'], fingerprint)
await f.write(data) 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.edgedb.add_file( try:
await current_app.convex.add_file(
file_name=fname, file_name=fname,
file_size=file_size_pretty, file_size=file_size_pretty,
note=note, note=note,
content_type=content_type, content_type=content_type,
uploaded_at=datetime.now(timezone.utc),
expires_at=expires_at_dt, expires_at=expires_at_dt,
storage_id=storage_id,
user_id=user['sub'], 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
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.edgedb.add_file( try:
await current_app.convex.add_file(
file_name=fname, file_name=fname,
file_size=file_size_pretty, file_size=file_size_pretty,
note=note, note=note,
content_type="text/plain", content_type='text/plain',
uploaded_at=datetime.now(timezone.utc),
expires_at=expires_at_dt, expires_at=expires_at_dt,
storage_id=storage_id,
user_id=user['sub'], user_id=user['sub'],
) )
return jsonify({"ok": True})
# --- Background cleanup ------------------------------------------------------
async def cleanup_task():
"""Hourly cleanup of expired files based on EdgeDB."""
await asyncio.sleep(3) # allow app startup
while True:
try:
now = datetime.now(timezone.utc)
expired = await current_app.edgedb.get_expired_files(now)
if not expired:
await asyncio.sleep(3600)
continue
upload_dir: Path = current_app.upload_folder # ensure Path
removed_ids: list[str] = []
for rec in expired:
try:
# Defensive: only touch files under your upload dir
fpath = (upload_dir / rec['file_name']).resolve()
if upload_dir.resolve() in fpath.parents or fpath == upload_dir.resolve():
fpath.unlink(missing_ok=True)
removed_ids.append(rec['file_id'])
else:
current_app.logger.warning("Refusing to delete outside upload dir: %s", fpath)
except Exception as e:
current_app.logger.exception("Failed to delete file %s (%s)", rec['file_name'], rec['file_id'])
# Remove DB rows for files we actually deleted from disk
if removed_ids:
try:
await current_app.edgedb.delete_files_by_ids(removed_ids)
current_app.logger.info("Deleted %d expired files from disk and database: %s", len(removed_ids), ", ".join(removed_ids))
except Exception: except Exception:
current_app.logger.exception("Failed to delete DB rows for expired files") if not reused_orphan_storage_id and orphan_registry:
else: await orphan_registry.remember(user['sub'], fingerprint, storage_id)
current_app.logger.info("No files where expired or deleted at %s", now.isoformat()) raise
except Exception: add_wide_event_context(nanoshare={"operation_status": "uploaded", "content_type": content_type})
current_app.logger.exception("Cleanup task iteration failed") return jsonify({'ok': True})
await asyncio.sleep(3600) # every hour
@upload_bp.before_app_serving
async def start_cleanup():
asyncio.create_task(cleanup_task())
+11 -4
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env -S uv run --script
import quart_flask_patch import quart_flask_patch
import asyncio import asyncio
asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy()) asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
@@ -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=5500) app.run(debug=WEB_DEBUG, port=5502)
Submodule
+1
Submodule servicelink added at 094bdc8c56
+2 -2
View File
@@ -35,7 +35,7 @@
</td> </td>
<td>{{ access.file_note }}</td> <td>{{ access.file_note }}</td>
<td><span class="badge">{{ access.status }}</span></td> <td><span class="badge">{{ access.status }}</span></td>
<td>{{ access.ip }}</td> <td>{{ access.ip_address }}</td>
<td>{{ access.user_agent }}</td> <td>{{ access.user_agent }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -65,7 +65,7 @@
const datetime = timeEl.getAttribute("datetime"); const datetime = timeEl.getAttribute("datetime");
if (!datetime) return; if (!datetime) return;
const date = new Date(datetime); const date = new Date(Number.parseInt(datetime));
timeEl.title = date.toISOString(); timeEl.title = date.toISOString();
timeEl.textContent = date.toLocaleString(undefined, { timeEl.textContent = date.toLocaleString(undefined, {
+210
View File
@@ -0,0 +1,210 @@
{% extends "base.htm" %}
{% block title %}NanoShare - Edit file{% endblock %}
{% block meta %}
<meta name="description" content="NanoShare file editing page.">
<meta name="robots" content="noindex, nofollow" />
{% endblock %}
{% block head %}
<style>
.file-edit-page { margin: clamp(16px, 3vw, 32px) auto; }
.info-line { margin-top: 12px; color: var(--muted); font-size: .95rem; }
.save-row { margin-top: 18px; display: flex; justify-content: space-evenly; align-items: center; gap: 10px; flex-wrap: wrap; }
.status-row { margin-top: 8px; text-align: center; }
.status { color: var(--muted); font-size: .92rem; }
.status.ok { color: #8ee28e; }
.status.err { color: #f08f8f; }
.btn-danger { border-color: #8f3b3b; color: #ffd4d4; }
.btn-danger:hover { background: #4d1f1f; }
.meta-table-wrap { margin-top: 10px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.meta-table { width: 100%; border-collapse: collapse; }
.meta-table th, .meta-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
.meta-table tr:last-child th, .meta-table tr:last-child td { border-bottom: 0; }
.meta-table th { width: 160px; color: var(--muted); font-weight: 700; }
.danger-zone { margin-top: 20px; border: 1px solid #6a2c2c; border-radius: 12px; padding: 12px; background: rgba(120, 32, 32, 0.14); }
.danger-zone h2 { margin: 0 0 6px; font-size: 1rem; }
.danger-zone p { margin: 0 0 10px; color: var(--muted); font-size: .92rem; }
</style>
{% endblock %}
{% block content %}
<main class="file-edit-page">
<section class="card" aria-labelledby="edit-title">
<h1 id="edit-title" class="page-title">Edit file</h1>
<p class="subtle">Update filename, note and expiration date.</p>
<div class="meta-table-wrap" role="region" aria-label="File metadata">
<table class="meta-table">
<tbody>
<tr>
<th scope="row">URL</th>
<td><a href="{{ share_url }}">{{ share_url }}</a></td>
</tr>
<tr>
<th scope="row">File ID</th>
<td><code>{{ file_id }}</code></td>
</tr>
<tr>
<th scope="row">Uploaded at</th>
<td><time class="local-time" data-ts="{{ file.uploaded_at }}"></time></td>
</tr>
<tr>
<th scope="row">File size</th>
<td><span class="badge">{{ file.file_size }}</span></td>
</tr>
</tbody>
</table>
</div>
<div class="field">
<label for="fileName" class="label">Filename</label>
<input id="fileName" class="input" type="text" maxlength="255" value="{{ file.file_name }}" />
</div>
<div class="field">
<label for="note" class="label">Note</label>
<input id="note" class="input" type="text" maxlength="500" value="{{ file.note or '' }}" />
</div>
<div class="row">
<div class="field">
<label for="expiresMode" class="label">Expiration</label>
<select id="expiresMode" class="select">
<option value="1h">1 hour</option>
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
<option value="never">Never</option>
<option value="custom">Custom date</option>
</select>
</div>
<div class="field" id="customWrap" style="display:none;">
<label for="customExpire" class="label">Custom expiration</label>
<input type="datetime-local" id="customExpire" class="datetime" />
</div>
</div>
<div class="save-row">
<button id="saveBtn" class="btn" type="button">Save changes</button>
<a class="btn btn-ghost" href="{{ url_for('side_main.file_info', file_id=file_id) }}">View info</a>
<a class="btn btn-ghost" href="{{ url_for('side_main.files_list') }}">Back to files</a>
</div>
<div class="status-row"><span id="status" class="status"></span></div>
<section class="danger-zone" aria-labelledby="danger-title">
<h2 id="danger-title">Danger zone</h2>
<p>Deleting a file is permanent and cannot be undone.</p>
<button id="deleteBtn" class="btn btn-ghost btn-danger" type="button">Delete file</button>
</section>
</section>
</main>
<script>
const fileId = '{{ file_id }}';
const initialExpires = '{{ file.expires_at }}';
const expiresMode = document.getElementById('expiresMode');
const customWrap = document.getElementById('customWrap');
const customExpire = document.getElementById('customExpire');
const statusEl = document.getElementById('status');
function formatDate(value) {
if (!value) return 'Never';
const date = new Date(Number.parseInt(String(value), 10));
if (Number.isNaN(date.getTime())) return 'Invalid date';
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
function toLocalInputValue(timestampMs) {
const d = new Date(Number.parseInt(String(timestampMs), 10));
if (Number.isNaN(d.getTime())) return '';
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 16);
}
function setStatus(msg, type) {
statusEl.textContent = msg;
statusEl.className = 'status';
if (type) statusEl.classList.add(type);
}
function syncExpireUI() {
customWrap.style.display = expiresMode.value === 'custom' ? 'block' : 'none';
}
if (initialExpires) {
expiresMode.value = 'custom';
customExpire.value = toLocalInputValue(initialExpires);
} else {
expiresMode.value = 'never';
}
syncExpireUI();
expiresMode.addEventListener('change', syncExpireUI);
document.querySelectorAll('time.local-time').forEach((el) => {
el.textContent = formatDate(el.dataset.ts || '');
});
document.getElementById('saveBtn').addEventListener('click', async () => {
const fileName = document.getElementById('fileName').value.trim();
const note = document.getElementById('note').value.trim();
if (!fileName) {
setStatus('Filename is required.', 'err');
return;
}
let expires = expiresMode.value;
if (expiresMode.value === 'custom') {
if (!customExpire.value) {
setStatus('Pick a custom date.', 'err');
return;
}
expires = new Date(customExpire.value).toISOString();
}
setStatus('Saving...');
try {
const response = await fetch(`/api/file/${encodeURIComponent(fileId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_name: fileName, note, expires }),
});
const data = await response.json();
if (!response.ok || !data.ok) {
throw new Error(data.error || 'Could not update file');
}
setStatus('Saved.', 'ok');
} catch (error) {
setStatus(error.message || 'Could not update file.', 'err');
}
});
document.getElementById('deleteBtn').addEventListener('click', async () => {
const ok = window.confirm('Delete this file? This cannot be undone.');
if (!ok) return;
setStatus('Deleting...');
try {
const response = await fetch(`/api/file/${encodeURIComponent(fileId)}`, {
method: 'DELETE',
});
const data = await response.json();
if (!response.ok || !data.ok) {
throw new Error(data.error || 'Could not delete file');
}
window.location.href = '{{ url_for("side_main.files_list") }}';
} catch (error) {
setStatus(error.message || 'Could not delete file.', 'err');
}
});
</script>
{% endblock %}
+210
View File
@@ -0,0 +1,210 @@
{% extends "base.htm" %}
{% block title %}NanoShare - File info{% endblock %}
{% block meta %}
<meta name="description" content="NanoShare file details and access information.">
<meta name="robots" content="noindex, nofollow" />
{% endblock %}
{% block head %}
<style>
.file-info-page { padding: clamp(16px, 3vw, 28px); }
.top-grid { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 16px; align-items: start; }
.kv { display: grid; grid-template-columns: 180px 1fr; gap: 10px 14px; margin-top: 14px; }
.kv dt { color: var(--muted); font-weight: 700; }
.kv dd { margin: 0; word-break: break-word; }
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: .92rem; }
.preview-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: color-mix(in srgb, var(--panel-2) 60%, transparent); }
.preview-head { padding: 10px 12px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: .9rem; font-weight: 700; }
.preview-body { min-height: 230px; display: grid; place-items: center; padding: 10px; }
.preview-body img, .preview-body video, .preview-body audio, .preview-body iframe { width: 100%; max-width: 100%; border: 0; border-radius: 8px; }
.preview-body video { max-height: 260px; }
.preview-body pre { width: 100%; max-height: 260px; overflow: auto; margin: 0; padding: 10px; border-radius: 8px; border: 1px solid var(--border); background: rgba(0,0,0,.16); white-space: pre-wrap; word-break: break-word; }
.preview-note { color: var(--muted); font-size: .88rem; text-align: center; }
@media (max-width: 980px) { .top-grid { grid-template-columns: 1fr; } }
@media (max-width: 720px) { .kv { grid-template-columns: 1fr; gap: 4px; } }
</style>
{% endblock %}
{% block content %}
<main class="file-info-page">
<section class="card" style="padding: clamp(18px, 2.6vw, 28px);">
<div class="top-grid">
<div>
<h1 class="page-title">File details</h1>
<p class="subtle">Everything about this file, including expiration date and recent accesses.</p>
<dl class="kv">
<dt>Filename</dt>
<dd>{{ file.file_name }}</dd>
<dt>Note</dt>
<dd>{{ file.note or 'No note' }}</dd>
<dt>Size</dt>
<dd><span class="badge">{{ file.file_size }}</span></dd>
<dt>Uploaded at</dt>
<dd><time class="local-time" data-ts="{{ file.uploaded_at }}"></time></dd>
<dt>Expires at</dt>
<dd>
{% if file.expires_at %}
<time class="local-time" data-ts="{{ file.expires_at }}"></time>
{% else %}
Never
{% endif %}
</dd>
<dt>Public URL</dt>
<dd><a class="mono" href="{{ share_url }}">{{ share_url }}</a></dd>
</dl>
</div>
<aside class="preview-card" aria-label="File preview">
<div class="preview-head">Preview</div>
<div id="previewBody" class="preview-body">
<p class="preview-note">Loading preview...</p>
</div>
</aside>
</div>
<div class="toolbar">
<a class="btn" href="{{ url_for('side_main.file_edit', file_id=file.file_id) }}">Edit</a>
<button id="copyUrlBtn" class="btn btn-ghost" type="button">Copy link</button>
<a class="btn btn-ghost" href="{{ url_for('side_main.files_list') }}">Back to files</a>
</div>
</section>
<section class="card" style="padding: clamp(18px, 2.6vw, 28px); margin-top: 16px;">
<h2 style="margin:0 0 8px;">Access history</h2>
<p class="subtle">Latest request metadata for this file.</p>
<div class="table-wrap" role="region" aria-label="Access history" tabindex="0">
<table class="table">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Status</th>
<th scope="col">IP</th>
<th scope="col">User Agent</th>
</tr>
</thead>
<tbody>
{% if accesses %}
{% for access in accesses %}
<tr>
<td><time class="local-time" data-ts="{{ access.accessed_at }}"></time></td>
<td><span class="badge">{{ access.status }}</span></td>
<td>{{ access.ip or '-' }}</td>
<td>{{ access.user_agent or '-' }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4">No access records yet.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</section>
</main>
<script>
function formatDate(value) {
if (!value) return '';
const raw = String(value);
let date;
if (/^\d+$/.test(raw)) {
date = new Date(Number.parseInt(raw, 10));
} else {
date = new Date(raw);
}
if (Number.isNaN(date.getTime())) return raw;
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
document.querySelectorAll('time.local-time').forEach((el) => {
const raw = el.dataset.ts || '';
el.textContent = formatDate(raw) || '-';
});
async function loadPreview() {
const previewBody = document.getElementById('previewBody');
if (!previewBody) return;
let url = '{{ share_url }}';
if (window.location.protocol === 'https:' && url.startsWith('http://')) {
const parsed = new URL(url);
if (parsed.host === window.location.host) {
parsed.protocol = 'https:';
url = parsed.toString();
}
}
try {
const response = await fetch(url, { method: 'GET' });
if (!response.ok) {
previewBody.innerHTML = '<p class="preview-note">Preview unavailable.</p>';
return;
}
const contentType = (response.headers.get('content-type') || '').toLowerCase();
if (contentType.startsWith('image/')) {
previewBody.innerHTML = `<img src="${url}" alt="File preview">`;
return;
}
if (contentType.startsWith('video/')) {
previewBody.innerHTML = `<video controls src="${url}"></video>`;
return;
}
if (contentType.startsWith('audio/')) {
previewBody.innerHTML = `<audio controls src="${url}"></audio>`;
return;
}
if (contentType.includes('pdf')) {
previewBody.innerHTML = `<iframe src="${url}" title="PDF preview" height="260"></iframe>`;
return;
}
if (contentType.startsWith('text/') || contentType.includes('json') || contentType.includes('xml')) {
const text = await response.text();
const safe = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
previewBody.innerHTML = `<pre>${safe.slice(0, 6000) || 'Empty text file.'}</pre>`;
return;
}
previewBody.innerHTML = `<p class="preview-note">No inline preview for this file type.<br><a href="${url}" target="_blank" rel="noopener">Open file</a></p>`;
} catch {
previewBody.innerHTML = '<p class="preview-note">Preview unavailable.</p>';
}
}
loadPreview();
document.getElementById('copyUrlBtn')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText('{{ share_url }}');
const btn = document.getElementById('copyUrlBtn');
if (!btn) return;
const label = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => { btn.textContent = label; }, 1200);
} catch {
alert('Could not copy link.');
}
});
</script>
{% endblock %}
+1 -1
View File
@@ -75,7 +75,7 @@
const datetime = timeEl.getAttribute("datetime"); const datetime = timeEl.getAttribute("datetime");
if (!datetime) return; if (!datetime) return;
const date = new Date(datetime); const date = new Date(Number.parseInt(datetime));
timeEl.title = date.toISOString(); timeEl.title = date.toISOString();
timeEl.textContent = date.toLocaleString(undefined, { timeEl.textContent = date.toLocaleString(undefined, {
+20 -10
View File
@@ -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',()=>{
+37
View File
@@ -0,0 +1,37 @@
body {
font-family: Arial, sans-serif;
background-color: var(--primary-color);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
text-align: center;
color: var(--text-color);
}
.container {
background-color: var(--secondary-color);
padding: 40px;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
max-width: 920px;
}
h1 {
color: #e74c3c;
font-size: 48px;
margin: 0;
font-family: 'Press Start 2P', cursive;
}
p {
font-size: 18px;
margin-top: 10px;
}
.blocked-ip {
font-weight: bold;
color: var(--text-muted);
}
+55
View File
@@ -0,0 +1,55 @@
:root {
--primary-color: #f0f0f0;
--secondary-color: white;
--nav-background-color: #333;
--text-color: #000;
--text-muted: #555;
--table-head-background-color: #e1e1e1;
--container-width: 580px;
--container-max-width: 903px;
--furry-button-color: #ffcaa6;
--furry-button-color-hover: #ffa974;
--furry-background-color: white;
--furry-decoration-color: #ff7f50;
--furry-list-element-color: #fff4e6;
--furry-list-element-color-hover: #ffeede;
--story-text-color: #33ff33;
--story-press-any-key-color: #ccc;
--story-background-color: radial-gradient(circle at center, #222 0%, #000 100%);
--story-button-color: #0d0d0d;
--story-button-hover-color: #003300;
--story-sidebar-background-color: black;
--story-boot-logo-color: #ffaa00;
}
@media (prefers-color-scheme: dark) {
:root {
/* --primary-color: #353535;
--secondary-color: #1f1f23; */
/* --primary-color: #2a2640;
--secondary-color: #3a3458; */
--primary-color: #1e1e2f;
--secondary-color: #2a2a3d;
--nav-background-color: #272424;
--text-color: #cec9da;
--text-muted: #999;
--table-head-background-color: var(--secondary-color);
--furry-button-color: #b67616;
--furry-list-element-color: #252231;
--furry-list-element-color-hover: var(--primary-color);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

+31
View File
@@ -0,0 +1,31 @@
User-agent: dotbot
Disallow: /
User-agent: CCBot
Disallow: /
User-agent: barkrowler
Disallow: /
User-agent: AhrefsBot
Disallow: /
User-agent: SemrushBot
Disallow: /
User-agent: serpstatbot
Disallow: /
User-agent: InternetMeasurement
Disallow: /
User-agent: CensysInspect
Disallow: /
User-agent: MJ12bot
Disallow: /
User-agent: *
Crawl-delay: 3600
Disallow: /.env
Disallow: /api/v1
+24
View File
@@ -0,0 +1,24 @@
# acl.conf
# 1. Disable the default user (recommended for production)
# This user is created by default with full access if no password is set.
user default off
# 2. Define a "root" or "admin" user with full access
user admin on >test +@all ~*
# 4. Define a "cache" user
# This user can read and write to keys starting with "cache:".
# This is great for application-specific keys.
user cache on >your_strong_webapp_password +@all ~cache:*
# 5. Define a "session" user
# This user can only get/set/del/expire keys related to caching.
user session on >your_strong_cache_password +GET +SET +SETEX +DEL +EXPIRE ~session:*
# 6. Define a "cache" user
# This user can read and write to keys starting with "cache:".
# This is great for application-specific keys.
user limiter on >your_strong_limiter_password +@all ~LIMITS:*
user pubsubuser on >strongpassword &printer:* +PUBLISH +SUBSCRIBE +PSUBSCRIBE +UNSUBSCRIBE +PUNSUBSCRIBE
+18
View File
@@ -0,0 +1,18 @@
services:
valkey:
image: valkey/valkey:latest
ports:
- 6379:6379
volumes:
- ./valkey_data:/data
- ./acl.conf:/usr/local/etc/valkey/acl.conf
command: valkey-server /usr/local/etc/valkey/acl.conf
user: "${UID}"
redisinsight:
image: redis/redisinsight:latest
ports:
- 5540:5540
volumes:
- ./redisinsight:/data
user: "${UID}"
+6
View File
@@ -0,0 +1,6 @@
echo "UID=${UID}" > .env
docker-compose up -d
cd ..
WEB_DEBUG=true \
./run.py
+119
View File
@@ -0,0 +1,119 @@
import asyncio
import importlib.util
import sys
import types
from pathlib import Path
import httpx
from quart import Blueprint, Quart, request
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
class FakeLimiter:
def exempt(self, func):
return func
def limit(self, *_args, **_kwargs):
def decorator(func):
return func
return decorator
class FakeLogger:
def __init__(self):
self.errors = []
def error(self, message):
self.errors.append(message)
def warning(self, message):
pass
class FailingConvex:
async def get_current_favicon(self):
raise httpx.ConnectError("[Errno -2] Name or service not known")
def load_module(module_name, module_path):
spec = importlib.util.spec_from_file_location(module_name, module_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def install_route_test_modules(monkeypatch, app, logger):
fake_setup = types.ModuleType("my_modules.app.setup")
fake_setup.app = app
fake_setup.LIMITER = FakeLimiter()
monkeypatch.setitem(sys.modules, "my_modules.app.setup", fake_setup)
fake_constens = types.ModuleType("my_modules.app.constens")
fake_constens.BLOCKED_IPS_ACCESSING_TIMES = 3
fake_constens.BLOCKED_IPS_STORED_TIMEFRAME = 60
monkeypatch.setitem(sys.modules, "my_modules.app.constens", fake_constens)
fake_logger_module = types.ModuleType("my_modules.app.logger")
fake_logger_module.logger = logger
monkeypatch.setitem(sys.modules, "my_modules.app.logger", fake_logger_module)
fake_functions = types.ModuleType("my_modules.functions")
fake_functions.get_ip = lambda: "203.0.113.10"
fake_functions.enforce_custom_limit = lambda *_args, **_kwargs: None
fake_functions.get_request_context = lambda: types.SimpleNamespace(path=request.path)
fake_functions.is_valid_uuid = lambda value: True
monkeypatch.setitem(sys.modules, "my_modules.functions", fake_functions)
def register_template_routes(app):
side_main = Blueprint("side_main", __name__)
async def index():
return "ok"
side_main.add_url_rule("/", "index", index)
side_main.add_url_rule("/files", "files_list", index)
side_main.add_url_rule("/access", "access_list", index)
app.register_blueprint(side_main)
auth_login = Blueprint("auth_login", __name__)
auth_login.add_url_rule("/login", "login", index)
auth_login.add_url_rule("/logout", "logout", index)
app.register_blueprint(auth_login)
def load_errors_and_basics(monkeypatch, app):
logger = FakeLogger()
install_route_test_modules(monkeypatch, app, logger)
root = Path(__file__).resolve().parents[1]
errors = load_module("test_routes_handeling_errorsAndBots", root / "routes" / "handeling" / "errorsAndBots.py")
basics = load_module("test_routes_handeling_basics", root / "routes" / "handeling" / "basics.py")
return errors, basics, logger
def test_convex_connect_error_is_returned_as_global_database_error(monkeypatch):
async def run_test():
app = Quart(__name__, template_folder=str(Path(__file__).resolve().parents[1] / "templates" / "side"))
register_template_routes(app)
app.convex = FailingConvex()
_errors, basics, logger = load_errors_and_basics(monkeypatch, app)
app.register_blueprint(basics.basic_bp)
response = await app.test_client().get("/favicon.ico")
assert response.status_code == 504
assert any("Name or service not known" in str(error) for error in logger.errors)
asyncio.run(run_test())
def test_api_convex_connect_error_returns_json_database_error(monkeypatch):
async def run_test():
app = Quart(__name__, template_folder=str(Path(__file__).resolve().parents[1] / "templates" / "side"))
load_errors_and_basics(monkeypatch, app)
@app.get("/api/failing")
async def failing_api():
raise httpx.ConnectError("[Errno -2] Name or service not known")
response = await app.test_client().get("/api/failing")
payload = await response.get_json()
assert response.status_code == 504
assert payload["error"] == "Database Error"
asyncio.run(run_test())
+106
View File
@@ -0,0 +1,106 @@
import asyncio
import importlib.util
import json
import sys
import types
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from quart import Quart
from servicelink import Request, handle_envelope
# Load routes/api/link.py in isolation. Importing it as `routes.api.link` would
# run routes/__init__.py -> my_modules.app.setup -> constens (asyncio.run at
# import), which fails under pytest's output capture. We stub my_modules.app.setup
# with a no-op limiter; link.py's other deps (my_modules.expiry/file_meta,
# servicelink, quart) are side-effect free.
class _NoOpLimiter:
def limit(self, *args, **kwargs):
return lambda fn: fn
_setup_stub = types.ModuleType('my_modules.app.setup')
_setup_stub.LIMITER = _NoOpLimiter()
sys.modules.setdefault('my_modules.app.setup', _setup_stub)
_LINK_PATH = Path(__file__).resolve().parents[1] / 'routes' / 'api' / 'link.py'
_spec = importlib.util.spec_from_file_location('picoshare_link_under_test', _LINK_PATH)
link = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(link)
class FakeConvex:
def __init__(self):
self.files = {}
async def decode_access_token_payload(self, access_token, required_scope=None, required_scopes=None):
if access_token == 'good':
return {'sub': 'user_123', 'scope': 'all'}
if access_token == 'nomesh':
return {'sub': 'user_123', 'scope': 'files'}
return {'error': 'invalid token'}
async def send_to_storage(self, data, content_type):
return 'storage_1'
async def add_file(self, file_name, file_size, note, content_type, expires_at, storage_id, user_id):
self.files[file_name] = {
'file_name': file_name,
'note': note,
'content_type': content_type,
'storage_id': storage_id,
'user_id': user_id,
}
return self.files[file_name]
async def get_file(self, file_id):
return self.files.get(file_id)
def _call(envelope, token=None):
# Call the handler the /rpc route delegates to, bypassing the LIMITER/size-cap
# wrapper; exercises the real verify (mesh scope) + dispatch + handlers.
async def run():
app = Quart(__name__)
app.convex = FakeConvex()
async with app.test_request_context('/rpc', method='POST'):
status, body, _ = await handle_envelope(
link.router,
json.dumps(envelope).encode('utf-8'),
authorization=f'Bearer {token}' if token else None,
verify=link.verify,
content_type='application/json',
)
return status, json.loads(body)
return asyncio.run(run())
def _request(method, params=None):
return Request.create(method, params or {}, source='browser-cli', target='picoshare').to_dict()
def test_text_upload_roundtrip():
status, body = _call(_request('files.upload', {'text': 'hello mesh', 'file_name': 'note.txt', 'note': 'scraped'}), token='good')
assert status == 200
assert body['ok'] is True
assert body['result']['file_name'] == 'note.txt'
assert body['result']['content_type'] == 'text/plain'
assert body['result']['size'] == len('hello mesh')
def test_upload_requires_content():
status, body = _call(_request('files.upload', {'file_name': 'x'}), token='good')
assert status == 422
assert body['error']['code'] == 'invalid_params'
def test_get_missing_file_is_not_found():
status, body = _call(_request('files.get', {'file_id': 'ghost'}), token='good')
assert status == 404
assert body['error']['code'] == 'not_found'
def test_missing_token_is_unauthorized():
status, body = _call(_request('files.list'))
assert status == 401
assert body['error']['code'] == 'unauthorized'
def test_token_without_mesh_scope_is_forbidden():
status, body = _call(_request('files.list'), token='nomesh')
assert status == 403
assert body['error']['code'] == 'forbidden'
+148
View File
@@ -0,0 +1,148 @@
import asyncio
import importlib
import sys
import types
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from quart import Quart, g, session
class FakeLogger:
def __init__(self):
self.records = []
async def info(self, message):
self.records.append(("info", message))
async def warning(self, message):
self.records.append(("warning", message))
async def error(self, message):
self.records.append(("error", message))
class FakeIPBotManager:
def is_client_ip_always_allowed(self, ip):
return False
class FakeSetupApp:
def before_request(self, func):
return func
def context_processor(self, func):
return func
class FakeConvex:
async def is_ip_address_whitelisted_or_blocked(self, ip_address):
return {"whiteliste": False, "blocked": False}
async def is_path_blocked(self, path):
return False
class FailingConvex:
async def is_ip_address_whitelisted_or_blocked(self, ip_address):
raise RuntimeError("convex down")
def load_middleware(monkeypatch, client_ip="203.0.113.10", logger=None):
fake_errors = types.ModuleType("routes.handeling.errorsAndBots")
async def maybe_a_hacker():
return "blocked", 418
fake_errors.maybe_a_hacker = maybe_a_hacker
fake_constens = types.ModuleType("my_modules.app.constens")
fake_constens.THE_IP_BOT_MANAGER = FakeIPBotManager()
fake_constens.SKIP_PATH_PREFIXES = ("/static", "/storage")
fake_constens.SKIP_PATHS = ("/favicon.ico",)
fake_logger_module = types.ModuleType("my_modules.app.logger")
fake_logger_module.logger = logger or FakeLogger()
fake_functions = types.ModuleType("my_modules.functions")
fake_functions.get_ip = lambda: client_ip
fake_setup = types.ModuleType("my_modules.app.setup")
fake_setup.app = FakeSetupApp()
monkeypatch.setitem(sys.modules, "routes.handeling.errorsAndBots", fake_errors)
monkeypatch.setitem(sys.modules, "my_modules.app.constens", fake_constens)
monkeypatch.setitem(sys.modules, "my_modules.app.logger", fake_logger_module)
monkeypatch.setitem(sys.modules, "my_modules.functions", fake_functions)
monkeypatch.setitem(sys.modules, "my_modules.app.setup", fake_setup)
sys.modules.pop("my_modules.middleware", None)
return importlib.import_module("my_modules.middleware")
def test_middleware_adds_user_and_client_context(monkeypatch):
async def run_test():
middleware = load_middleware(monkeypatch)
monkeypatch.setattr(middleware, "get_ip", lambda: "203.0.113.10")
monkeypatch.setattr(middleware, "logger", FakeLogger())
app = Quart(__name__)
app.secret_key = "test-secret"
app.convex = FakeConvex()
async with app.test_request_context("/files"):
g.wide_event = {}
session["user"] = {"sub": "user_123", "preferred_username": "demo"}
session["login_at"] = 1
result = await middleware.custom_middleware()
assert result is None
assert g.wide_event["user"]["id"] == "user_123"
assert g.wide_event["user"]["name"] == "demo"
assert g.wide_event["user"]["authenticated"] is True
assert "authenticated" not in g.wide_event["session"]
assert g.wide_event["session"]["permanent"] is True
assert g.wide_event["session"]["login_at_unix"] == 1
assert g.wide_event["session"]["age_seconds"] >= 0
assert g.wide_event["client"]["ip"] == "203.0.113.10"
asyncio.run(run_test())
def test_middleware_marks_missing_convex_as_skipped(monkeypatch):
async def run_test():
middleware = load_middleware(monkeypatch, client_ip="203.0.113.11")
monkeypatch.setattr(middleware, "logger", FakeLogger())
app = Quart(__name__)
app.secret_key = "test-secret"
async with app.test_request_context("/files"):
g.wide_event = {}
result = await middleware.custom_middleware()
assert result is None
assert g.wide_event["client"]["ip"] == "203.0.113.11"
assert g.wide_event["security"] == {
"convex_missing": True,
"middleware_skipped": True,
}
asyncio.run(run_test())
def test_middleware_records_convex_security_failures(monkeypatch):
async def run_test():
fake_logger = FakeLogger()
middleware = load_middleware(monkeypatch, client_ip="203.0.113.12", logger=fake_logger)
app = Quart(__name__)
app.secret_key = "test-secret"
app.convex = FailingConvex()
async with app.test_request_context("/files"):
g.wide_event = {}
result = await middleware.custom_middleware()
assert result is None
assert g.wide_event["client"]["ip"] == "203.0.113.12"
assert g.wide_event["security"] == {"ip_lookup_failed": True}
assert g.wide_event["error"]["type"] == "RuntimeError"
assert fake_logger.records == [("error", "[MIDDLEWARE] Convex ip_lookup failed: convex down")]
asyncio.run(run_test())
Generated
+474 -681
View File
File diff suppressed because it is too large Load Diff