Initial commit of StudyCalendar
Some checks are pending
CI / test (push) Waiting to run

This commit is contained in:
Murilo Scolari 2026-09-16 20:00:04 -03:00
parent e681295b55
commit 573152a3f0
17 changed files with 3577 additions and 0 deletions

26
.env.example Normal file
View file

@ -0,0 +1,26 @@
# Ambiente deve ser explicitamente production ou development.
APP_ENV=production
# Gere com: python -c "import secrets; print(secrets.token_urlsafe(48))"
FLASK_SECRET_KEY=
TRUSTED_HOSTS=calendario.seudominio.com
DATABASE_PATH=/var/lib/studycalendar/calendario.db
SESSION_COOKIE_SECURE=true
SESSION_COOKIE_SAMESITE=Lax
ENABLE_HSTS=true
ALLOW_REGISTRATION=false
MAX_FEEDS_PER_USER=20
MAX_MANUAL_EVENTS_PER_USER=10000
MAX_NOTIFICATION_RULES_PER_USER=20
MAX_EVENT_RESULTS=5000
ICS_HTTPS_ONLY=true
ICS_CONNECT_TIMEOUT=5
ICS_READ_TIMEOUT=15
ICS_TOTAL_TIMEOUT=30
ICS_MAX_BYTES=5242880
ICS_MAX_EVENTS=5000
ICS_MAX_REDIRECTS=3
# Use 0 sem proxy; configure o número exato de proxies confiáveis quando houver.
TRUSTED_PROXY_COUNT=0
MAX_CONTENT_LENGTH=65536
LOG_LEVEL=INFO
SCHEDULER_LOCK_PATH=/run/studycalendar/scheduler.lock

27
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,27 @@
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: pip
- run: python -m pip install --requirement requirements-dev.txt
- run: python -m pip check
- run: python -m pip_audit --requirement requirements.txt
- run: pytest -q
env:
APP_ENV: test
DATABASE_PATH: /tmp/studycalendar-ci.db
- run: node --check static/js/app.js
- run: node --check static/sw.js

2
.gitignore vendored
View file

@ -20,3 +20,5 @@ htmlcov/
.DS_Store
.idea/
.vscode/
cookie.txt
*.log

68
README.md Normal file
View file

@ -0,0 +1,68 @@
# StudyCalendar backend
## Desenvolvimento
O modo de desenvolvimento é explícito e o servidor embutido escuta apenas loopback:
```bash
python -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
APP_ENV=development DATABASE_PATH=/tmp/studycalendar-dev.db .venv/bin/python app.py
```
A chave aleatória gerada no modo development muda a cada reinício. Defina `FLASK_SECRET_KEY` se quiser preservar sessões.
## Deploy seguro
1. Copie as variáveis de `.env.example` para o gerenciador de segredos do serviço. O arquivo `.env` é ignorado; nunca grave a chave no repositório.
2. Gere uma chave exclusiva com `python -c "import secrets; print(secrets.token_urlsafe(48))"`.
3. Defina `TRUSTED_HOSTS` com os nomes DNS públicos exatos, separados por vírgula. Não use curingas amplos.
4. Termine TLS em um proxy reverso, mantenha `SESSION_COOKIE_SECURE=true`, `ENABLE_HSTS=true` e exponha somente HTTPS.
5. Mantenha `ALLOW_REGISTRATION=false` e provisione usuários por um fluxo administrativo controlado, ou habilite cadastro somente quando houver uma decisão explícita de produto e proteção adicional no proxy/WAF.
6. Execute o web app com `gunicorn --workers 2 --bind 127.0.0.1:8000 app:app`.
7. Execute exatamente uma instância separada de `python scheduler_process.py`. Configure `SCHEDULER_LOCK_PATH` em diretório gravável apenas pelo serviço. O lock impede duplicação acidental no mesmo host; em múltiplos hosts, use somente uma unidade/replica do scheduler.
8. Dê ao usuário do serviço acesso mínimo ao diretório de `DATABASE_PATH` e faça backups consistentes do SQLite.
Crie o primeiro usuário de forma interativa, sem expor a senha na linha de comando:
```bash
APP_ENV=production FLASK_SECRET_KEY='...' TRUSTED_HOSTS=calendario.seudominio.com DATABASE_PATH=/var/lib/studycalendar/calendario.db \
.venv/bin/flask --app app create-user
```
Não inicie o scheduler em hooks do Gunicorn nem em cada worker. O processo web nunca inicia tarefas em importação.
As rotas web apenas enfileiram sincronizações no SQLite; downloads e parsing são executados pelo scheduler dedicado para não ocupar workers HTTP.
### Proxy reverso
`TRUSTED_PROXY_COUNT=0` é o padrão seguro e ignora `X-Forwarded-*`. Altere para o número exato de proxies sob seu controle entre o cliente e Gunicorn. Nunca aceite tráfego direto no Gunicorn quando esse valor for maior que zero. O endereço resultante é usado pelo rate limit de autenticação.
O limitador é local e em memória: não precisa de Redis e é suficiente para uma implantação pequena, mas o limite é aplicado por worker/processo. Para escala horizontal, complemente-o no proxy/WAF sem remover os limites da aplicação.
### CSRF e same-origin
O backend não habilita CORS. Navegadores same-origin existentes são validados por `Origin`/`Referer`. Clientes de API podem fazer `GET /api/auth/csrf`, manter o cookie de sessão e enviar o valor retornado no header `X-CSRF-Token` em `POST`, `PUT`, `PATCH` e `DELETE`.
### Feeds ICS
HTTPS e porta 443 são obrigatórios por padrão. Cada destino e redirect tem DNS validado; endereços não globais IPv4/IPv6 são bloqueados. TLS sempre é verificado, redirects e corpo são limitados, proxies de ambiente são ignorados e o peer conectado é comparado obrigatoriamente ao DNS validado. URLs não são registradas em log.
Para permitir HTTP público em ambiente legado, defina `ICS_HTTPS_ONLY=false`, reconhecendo a perda de confidencialidade e integridade. Não há opção para desativar verificação TLS.
### Banco legado
A antiga migração que atribuía registros sem proprietário ao usuário `1` foi removida. Bancos anteriores ao schema multiusuário devem ser migrados offline com uma associação de proprietário definida pelo operador; a aplicação não inventa ownership.
## Testes
```bash
.venv/bin/pytest -q
.venv/bin/pip-audit --requirement requirements.txt
```
Os arquivos `requirements*.txt` são locks com hashes. Para atualizá-los intencionalmente:
```bash
.venv/bin/pip-compile --generate-hashes --output-file=requirements.txt requirements.in
.venv/bin/pip-compile --generate-hashes --output-file=requirements-dev.txt requirements-dev.in
```

4
requirements-dev.in Normal file
View file

@ -0,0 +1,4 @@
-r requirements.in
pytest==9.1.1
pip-audit==2.10.0
pip-tools==7.6.1

615
requirements-dev.txt Normal file
View file

@ -0,0 +1,615 @@
#
# This file is autogenerated by pip-compile with Python 3.14
# by the following command:
#
# pip-compile --generate-hashes --no-index --output-file=requirements-dev.txt requirements-dev.in
#
apscheduler==3.11.3 \
--hash=sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30 \
--hash=sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a
# via -r requirements.in
blinker==1.9.0 \
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
# via flask
boolean-py==5.0 \
--hash=sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95 \
--hash=sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9
# via license-expression
build==1.6.1 \
--hash=sha256:51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53 \
--hash=sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7
# via pip-tools
cachecontrol[filecache]==0.14.4 \
--hash=sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b \
--hash=sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1
# via
# cachecontrol
# pip-audit
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
# via requests
charset-normalizer==3.5.1 \
--hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \
--hash=sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d \
--hash=sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5 \
--hash=sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b \
--hash=sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f \
--hash=sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f \
--hash=sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5 \
--hash=sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22 \
--hash=sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5 \
--hash=sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac \
--hash=sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838 \
--hash=sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90 \
--hash=sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626 \
--hash=sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4 \
--hash=sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369 \
--hash=sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b \
--hash=sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e \
--hash=sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee \
--hash=sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1 \
--hash=sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102 \
--hash=sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8 \
--hash=sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8 \
--hash=sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9 \
--hash=sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf \
--hash=sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0 \
--hash=sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031 \
--hash=sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e \
--hash=sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235 \
--hash=sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072 \
--hash=sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb \
--hash=sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c \
--hash=sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950 \
--hash=sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2 \
--hash=sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb \
--hash=sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e \
--hash=sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6 \
--hash=sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b \
--hash=sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2 \
--hash=sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d \
--hash=sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa \
--hash=sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2 \
--hash=sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818 \
--hash=sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032 \
--hash=sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71 \
--hash=sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96 \
--hash=sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687 \
--hash=sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8 \
--hash=sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3 \
--hash=sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61 \
--hash=sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9 \
--hash=sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1 \
--hash=sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a \
--hash=sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420 \
--hash=sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4 \
--hash=sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65 \
--hash=sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663 \
--hash=sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f \
--hash=sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591 \
--hash=sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a \
--hash=sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18 \
--hash=sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e \
--hash=sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f \
--hash=sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7 \
--hash=sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3 \
--hash=sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c \
--hash=sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3 \
--hash=sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7 \
--hash=sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96 \
--hash=sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486 \
--hash=sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3 \
--hash=sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6 \
--hash=sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b \
--hash=sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731 \
--hash=sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959 \
--hash=sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9 \
--hash=sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf \
--hash=sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8 \
--hash=sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e \
--hash=sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f \
--hash=sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885 \
--hash=sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0 \
--hash=sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506 \
--hash=sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2 \
--hash=sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0 \
--hash=sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e \
--hash=sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f \
--hash=sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e \
--hash=sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491 \
--hash=sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a \
--hash=sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20 \
--hash=sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449 \
--hash=sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af \
--hash=sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c \
--hash=sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712 \
--hash=sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7 \
--hash=sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a \
--hash=sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20 \
--hash=sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f \
--hash=sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3 \
--hash=sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9 \
--hash=sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e \
--hash=sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5 \
--hash=sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2 \
--hash=sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36 \
--hash=sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263 \
--hash=sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4 \
--hash=sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11 \
--hash=sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a \
--hash=sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3 \
--hash=sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375 \
--hash=sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa \
--hash=sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d \
--hash=sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5 \
--hash=sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99 \
--hash=sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d \
--hash=sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c \
--hash=sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488 \
--hash=sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6 \
--hash=sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc \
--hash=sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b \
--hash=sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f \
--hash=sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00 \
--hash=sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10 \
--hash=sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598 \
--hash=sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6 \
--hash=sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962 \
--hash=sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c \
--hash=sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08 \
--hash=sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab \
--hash=sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573 \
--hash=sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90 \
--hash=sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5 \
--hash=sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18 \
--hash=sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d \
--hash=sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af \
--hash=sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea \
--hash=sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c \
--hash=sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b \
--hash=sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6 \
--hash=sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8 \
--hash=sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774 \
--hash=sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004 \
--hash=sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a \
--hash=sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a \
--hash=sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2 \
--hash=sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2 \
--hash=sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa \
--hash=sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe \
--hash=sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3 \
--hash=sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc \
--hash=sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795 \
--hash=sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d \
--hash=sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc \
--hash=sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893 \
--hash=sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef \
--hash=sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d \
--hash=sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda \
--hash=sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17 \
--hash=sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30 \
--hash=sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7 \
--hash=sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5 \
--hash=sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182 \
--hash=sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f \
--hash=sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9 \
--hash=sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada \
--hash=sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876 \
--hash=sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a \
--hash=sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348 \
--hash=sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3 \
--hash=sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f \
--hash=sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0 \
--hash=sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f
# via requests
click==8.5.0 \
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
# via
# flask
# pip-tools
cyclonedx-python-lib==11.12.0 \
--hash=sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54 \
--hash=sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf
# via pip-audit
defusedxml==0.7.1 \
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
# via py-serializable
filelock==3.32.6 \
--hash=sha256:3f16ecd0117feae0dfc147e8c62eb5daeccd8bd800378c3ddf416de9b4feb6b1 \
--hash=sha256:a3f55a18af3652a94d8f47d6055df434f254ca1d02ef2524850c6d249ca2512c
# via cachecontrol
flask==3.1.3 \
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
# via -r requirements.in
gunicorn==26.2.0 \
--hash=sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447 \
--hash=sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3
# via -r requirements.in
icalendar==7.3.0 \
--hash=sha256:7bd001c8e648205e1bde5c6a5b77096598e8d0893dcf57755c6c597635620132 \
--hash=sha256:8355acfe17be81b368f0b1e3740817cea9b56ea889931f8f1a87c62f2d28db0b
# via -r requirements.in
idna==3.19 \
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
# via requests
iniconfig==2.3.0 \
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
# via pytest
itsdangerous==2.2.0 \
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
# via flask
jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
# via flask
license-expression==30.4.4 \
--hash=sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4 \
--hash=sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd
# via cyclonedx-python-lib
markdown-it-py==4.2.0 \
--hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \
--hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
# via rich
markupsafe==3.0.3 \
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
--hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
--hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
--hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
--hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
--hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
--hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
--hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
--hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
--hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
--hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
--hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
--hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
--hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
--hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
--hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
--hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
--hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
--hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
--hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
--hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
--hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
--hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
--hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
--hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
--hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
--hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
--hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
--hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
--hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
--hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
--hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
--hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
--hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
--hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
--hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
--hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
--hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
--hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
--hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
--hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
--hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
--hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via
# flask
# jinja2
# werkzeug
mdurl==0.1.2 \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
# via markdown-it-py
msgpack==1.2.2 \
--hash=sha256:06d95f61de7afe4f4ff908a6feebfcb070d0582ac87c9cf3cedf8551cf634516 \
--hash=sha256:0708afbf6a9587f0bfe479a9825c141d14d91e2f6a5c8103cf28bc96f4edb5d9 \
--hash=sha256:0883a1578168929fd1640fbbc4614773f1a130e419a8a817dc2918d9af1b651c \
--hash=sha256:0a652ceeededf71d3fa40c303a02a149d42338d310162367b91c539d4bd6e0a3 \
--hash=sha256:0dd9173c5ebaf5ecc5ca86e7ae1db92934e1d57b856f3dd90698941431f4fd77 \
--hash=sha256:0e3315de5a4b2920ccef48d96b4448025e064a10d0f5a250f6584477d839c8d4 \
--hash=sha256:0e91332144f69bc3018c91232fac26da580ef748fb8eaddd7914d4458001cc4f \
--hash=sha256:0fbc1bed8a535389b41882cfae66376e248cd1680eaa94fd83193c73e1d24986 \
--hash=sha256:11e8c421e117d1c36728b423d0402555cccbf0c6f53e288f0e75b6b12100d70f \
--hash=sha256:1510f24612d4b983dff6935d9273e02c320cfd525727fbcb58836a75f589fdbc \
--hash=sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc \
--hash=sha256:1e8cdd1f3e7cc52c751092a9bf740e81e6919ab109cd376ae2d965dad0bbae34 \
--hash=sha256:1f3af0baafd184436501004828bb3df64eeb2fc49dfe9d89abcf604956094563 \
--hash=sha256:1f6b6f8deb07d49090e1808c6ef9cb7d23ca17bef3aa6ed3e5e03df16606e60c \
--hash=sha256:226a62ffe99fe54c5c61d910ec64c3449b7766c3280bd286bf6c94838dde239a \
--hash=sha256:29cc2d5291711a52956a79a51f41c732329df39ad727c886bd8f0b5b9237a808 \
--hash=sha256:336525cc2688e43ea77dfb1a4ce012c8cde561835913801dbfcfdcf4111d8abb \
--hash=sha256:34e83e345194a2a51d8bd447dea9de2104f91e75b247f4735f14f04529f0746b \
--hash=sha256:352ed831042549cca8be23780e1fe7c9177e65ff02bf183509c4b4d33f671782 \
--hash=sha256:3e915d390d7068b257ca8b62f3fc59fad135c8631d1017ab03b0b924b07c5367 \
--hash=sha256:419a45c67a5c04213172a14b1864657e014665b77d7081b107a51707923dd39e \
--hash=sha256:42fd9260416885b4815caca5bdd14dfd5dda6cdade732d6c09104ef8f6228761 \
--hash=sha256:46ec851571d8f1b6e29794ebb9dd36f785008da6d14f57c702e60781d6caf648 \
--hash=sha256:4710d881d8fb047deed2485707409116722af2b992d3fefd73c7667c4e350839 \
--hash=sha256:4955accbd87f27beebef5f3ecc27503aa74cb016fb4f640868e749fd93194a35 \
--hash=sha256:4a4348705be86e029d04e741cf9ed0dfe03e942d7d3b92e838fa80d3aa2c3ebc \
--hash=sha256:4b554d8164ebb526892194f71dcd96ef1fefe0c250087498785d3ffc04a80be3 \
--hash=sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab \
--hash=sha256:51dd39d23cfdea0400ed3ff2d29d1e83bd951d3aea79dc89be5b701a09edfe23 \
--hash=sha256:53679573c75cce5f82359e0bd4e6a97809a6b9a9b7a48fd1ba592f4a82cddc84 \
--hash=sha256:55faa6f8395e23b848c535ad5dcb96b3462f37f5e7f4ac500d500434f7345da7 \
--hash=sha256:58ce37a4a54577115922385d37201d9a44d66d0167dfbbf4770a2e9bf8ea7ba3 \
--hash=sha256:59d5b93efa45fd09f620d0c9ba81cde339a2c9937af3eea42ee9653094ce6640 \
--hash=sha256:6195257a107bf25872ef84aab7295078271eea3ac6413f0506b631f6c9586ed5 \
--hash=sha256:652d1bf13d01bac8fd569def0fe76745e55bcda01e30aa6332d5947ea3788839 \
--hash=sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b \
--hash=sha256:68df2947921d449f6dcfeafd86cb2cdde13327a8b447534bbe4ee5aaf32a5695 \
--hash=sha256:6f53285f20d592ed309ee19e509cc4c77a3bda1db02ad67e8a0949bb227a5a6d \
--hash=sha256:73b0e05c32c3cfc3cd84994908e57430c0ebc6813abf905d3f18ff115d54df3f \
--hash=sha256:77c2e018417dc1d66f235e383877ee885b60ade9d29e494dd581e08af2cb1923 \
--hash=sha256:7826f16edc763e768404f55605ef85dfcf5857e729c1ed29e0d7c180be4fe6d8 \
--hash=sha256:7afa5431f6f3487c584187ca6c8e2a34e9b106529893b3e720eabb068f6ac970 \
--hash=sha256:7d095df2627e5dd59ac7b0c5ad627a671c76e6020171e03cbe4621a61f0562c3 \
--hash=sha256:7fe374ba76eb0ecca13a1703daa8fa85825a6ddddbb52d4c1a732fa524194683 \
--hash=sha256:82b1bdf293267afaadcc608b125e7fc6576bb0785a60c4fa7d07c7ab76ed76ec \
--hash=sha256:86f173a584f72f6164801f31866d22a581f60c991572cf922aed9ab8eb422b77 \
--hash=sha256:8b1415d02e9bf722672af8a90f90813265a0cd0b14163187261e54a5592bc949 \
--hash=sha256:8b2a281b556f120a43e591ea39915741b7ad54d4727b9c4350a0a11692252533 \
--hash=sha256:8c6321a414f8b4a8dc43976b2fa8349156434ca9adedd9a187b796f7e1d3d3fc \
--hash=sha256:8dc4487097571f7311188c3eca2a3e86cd1f1db4c37c7a017bcc3fd38486cbfe \
--hash=sha256:90986cc9aab9d7d1d8f38bcbf65d3f7ac83bdd90c35765db7d691b4829698cba \
--hash=sha256:9352e6cdb510a7b1a5d3ccaccec730e82e50cf3484a3af7bdaab19e23b9589ff \
--hash=sha256:935b1cfad9b908b0fa845010f4271df4c2f04e1cd26e3f18acd61a45f93c9e36 \
--hash=sha256:9b659d77f8726fa5e7038967dda6b68d53cf34472c094cfa5b845454713b90d5 \
--hash=sha256:9bd3d1557c3fe1a095068210708a03e3e4795973392af6f4047060e70abd9a6c \
--hash=sha256:9bf452ff4d4981f25a18e9476e002bcc9263e7928024aa4d7148e25f7be3f929 \
--hash=sha256:9d7fb25b4442fae0cb2590272d06ab4f6caa526ee36a994edb81e946b874813e \
--hash=sha256:9db1ba1c1e6a84245a9dd866265b56b8a1e9461549cc72ed296d8cbfbd32961b \
--hash=sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a \
--hash=sha256:9fd7f32e2f0fb334e7ecc5adb5cf0458785bd3a9d9d86f950e1715f101cebce5 \
--hash=sha256:a378e12ccc06d76efde115caf4073b7e5ff3cc18291d1341f9e65fb882e3f754 \
--hash=sha256:a4161eee7799863aee237c35c90427861f7b994416dd81ae829f560b0a81bdcd \
--hash=sha256:a9b4cf3685a135666d27d0d7a73fece74e2fad01d9b508fded89e843512f0e90 \
--hash=sha256:aa1120c653b76d8eafa50423b5eba06b5c9737f8692c74fa3afe03e84b8978ea \
--hash=sha256:b07c03f0da7e5279170df7745ddc732d526c8a198208936ec1a95c11ed2b2d5f \
--hash=sha256:b13b59e66f107cca1ba708dd5307179870ca1b15b19fcee7ccf722e5308d9212 \
--hash=sha256:b542ffc0a5c531eedc40419f291f1bd659aa8d4223408a5b51c88a2796083fd3 \
--hash=sha256:b5c696ae7cd7166b3657261adb855b461ff31f07823fdbae9de8bf80adfccc21 \
--hash=sha256:b68614fba0570349833b7dd999ff0aed4e5cc8d9eb6e3a7d4527be33c65e33d3 \
--hash=sha256:b8dd6c71d20c28d2d0eb0c51e7cccf3584afde3b1364f6629596186c9025bd54 \
--hash=sha256:b9b0c1f2aa7b0026b4bd50718100e8b04175e4f36e160aa852502377b5e572e7 \
--hash=sha256:c522420d78db2431887d45b518e304d86e27b9ad0b30f24e3806a6ad5d8bdbfc \
--hash=sha256:ccfd880988f8438d1c91c77d7edc58e70f4d2012e999167bc154c64c6f06ea6b \
--hash=sha256:cdb6cc6e1127d15879c47a8b3270716243da82d3e7feab1f5946872c75b3d60f \
--hash=sha256:cf66fb38703e61a486b01b56d43bb1f50698fbe99b6bd90feba10f24fab60b3b \
--hash=sha256:d13d07efbf655f9ae7a2352b630c52727b359005b21ba08a507585c9ac8c0896 \
--hash=sha256:d242f3c4ccf55b056e6cf901720dccde58f1df117898f2bbf3bcd6e38ec7c248 \
--hash=sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9 \
--hash=sha256:d3c247d457ae9079974c7ce3c665396754a6d2baff7eaa51332212a8a5a3f13b \
--hash=sha256:d886baa46b2532135e7320067e6a44edb09ba5883a6096b0f9c044533984b8a8 \
--hash=sha256:e05a94a0442de86818a30281c6cc2cb9cc7aa148386fd3541c4d4774b73cb3a9 \
--hash=sha256:e1b99ad34613d5f8477fa5cf99bc4eaeaf27965588007c102370cd9a78fe9de5 \
--hash=sha256:e2eb7ea0ac3911a7aac9d8aaa36d40f216d99455b3274cd3fac38181bcd910cf \
--hash=sha256:e497ee34e8a3342bbde51b27c22d8db05a651df3361dd3daef5b3ab0d66f3e04 \
--hash=sha256:f11e09f10210a91c169e39c7a5a1f9090eaa73ad75555fafad5023c3053c47ba \
--hash=sha256:f466049b8e1ec0854287bbe9a074316826fe0e08dcf707245f98b1ae49e92650 \
--hash=sha256:f80361592c13d7226b4379c8941529b63fe1a9d0e05d2de8f3306b70e522b53f \
--hash=sha256:ffdd2f4950daf7815490f23087963e3420175b9609520b7ff5df64d351159c22
# via cachecontrol
packageurl-python==0.17.6 \
--hash=sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25 \
--hash=sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9
# via cyclonedx-python-lib
packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via
# build
# pip-audit
# pip-requirements-parser
# pytest
# wheel
pip-api==0.0.35 \
--hash=sha256:14dea05d7b3b68f4c7582213e3ae8932bbba8e1f754bf06b95263d5fa6ab4363 \
--hash=sha256:66990d0da25378555cbb538db79384bab92d4617c6350dfd7a2126ba2a3a9894
# via pip-audit
pip-audit==2.10.0 \
--hash=sha256:16e02093872fac97580303f0848fa3ad64f7ecf600736ea7835a2b24de49613f \
--hash=sha256:427ea5bf61d1d06b98b1ae29b7feacc00288a2eced52c9c58ceed5253ef6c2a4
# via -r requirements-dev.in
pip-requirements-parser==32.0.1 \
--hash=sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526 \
--hash=sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3
# via pip-audit
pip-tools==7.6.1 \
--hash=sha256:6111c8b4b07fd14b7223ca921485b0e96cf66e20bf94da95eeed9845f510cb8f \
--hash=sha256:695556edeb647eb94ee8345cc7108657fdb7fb16b3876623a399b4f61bbede01
# via -r requirements-dev.in
platformdirs==4.11.8 \
--hash=sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081 \
--hash=sha256:f23abafea7dd4276d1f29104b83598d7dcc567cafd07c9c951e66665645437fc
# via pip-audit
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via pytest
py-serializable==2.1.0 \
--hash=sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103 \
--hash=sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304
# via cyclonedx-python-lib
pygments==2.21.0 \
--hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \
--hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c
# via
# pytest
# rich
pyparsing==3.3.2 \
--hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \
--hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc
# via pip-requirements-parser
pyproject-hooks==1.2.0 \
--hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \
--hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913
# via
# build
# pip-tools
pytest==9.1.1 \
--hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
--hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
# via -r requirements-dev.in
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via icalendar
pytz==2025.2 \
--hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \
--hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00
# via -r requirements.in
requests==2.34.2 \
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
# via
# -r requirements.in
# cachecontrol
# pip-audit
rich==15.0.0 \
--hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \
--hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36
# via pip-audit
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
sortedcontainers==2.4.0 \
--hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
--hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
# via cyclonedx-python-lib
tomli==2.4.1 \
--hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \
--hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \
--hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \
--hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \
--hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \
--hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \
--hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \
--hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \
--hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \
--hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \
--hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \
--hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \
--hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \
--hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \
--hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \
--hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \
--hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \
--hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \
--hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \
--hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \
--hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \
--hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \
--hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \
--hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \
--hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \
--hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \
--hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \
--hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \
--hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \
--hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \
--hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \
--hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \
--hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \
--hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \
--hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \
--hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \
--hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \
--hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \
--hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \
--hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \
--hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \
--hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \
--hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \
--hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \
--hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \
--hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \
--hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049
# via pip-audit
tomli-w==1.2.0 \
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
# via pip-audit
tzdata==2026.3 \
--hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \
--hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931
# via icalendar
tzlocal==5.4.4 \
--hash=sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4 \
--hash=sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15
# via apscheduler
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via requests
werkzeug==3.1.8 \
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
--hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44
# via flask
wheel==0.48.0 \
--hash=sha256:3217dcc807155e45db462d7ef2431f5ddda0d7273b700d05a67b271ceb1287ab \
--hash=sha256:94800765601e9171bf5d58d066e640662842bcedcbab982b2c90787a2c987322
# via pip-tools
# WARNING: The following packages were not pinned, but pip requires them to be
# pinned when the requirements file includes hashes and the requirement is not
# satisfied by a package already installed. Consider using the --allow-unsafe flag.
# pip
# setuptools

6
requirements.in Normal file
View file

@ -0,0 +1,6 @@
Flask==3.1.3
icalendar==7.3.0
requests==2.34.2
APScheduler==3.11.3
pytz==2025.2
gunicorn==26.2.0

34
run_dev.sh Executable file
View file

@ -0,0 +1,34 @@
#!/bin/bash
# Script para rodar o StudyCalendar em modo de desenvolvimento
# Define variáveis de ambiente
export APP_ENV=development
export DATABASE_PATH=/tmp/studycalendar-dev.db
echo "Iniciando o servidor web e o agendador em background..."
# Inicia o servidor web em background
venv/bin/python app.py &
WEB_PID=$!
# Inicia o agendador em background
venv/bin/python scheduler_process.py &
SCHEDULER_PID=$!
# Função para encerrar os dois processos ao apertar Ctrl+C
cleanup() {
echo ""
echo "Encerrando os processos..."
kill $WEB_PID
kill $SCHEDULER_PID
wait $WEB_PID 2>/dev/null
wait $SCHEDULER_PID 2>/dev/null
echo "Processos encerrados."
exit 0
}
# Captura o sinal de interrupção (Ctrl+C)
trap cleanup SIGINT SIGTERM
# Mantém o script rodando e aguarda os processos
wait

1867
sample_feed.ics Normal file

File diff suppressed because it is too large Load diff

54
scheduler_process.py Normal file
View file

@ -0,0 +1,54 @@
"""Processo único dedicado à sincronização periódica dos feeds."""
import fcntl
import logging
import os
from datetime import datetime, timedelta, timezone
from apscheduler.schedulers.blocking import BlockingScheduler
import database
import ics_sync
logging.basicConfig(level=os.environ.get('LOG_LEVEL', 'INFO'))
logger = logging.getLogger(__name__)
def sync_due_feeds():
now = datetime.now(timezone.utc)
for feed in database.get_all_feeds():
last_synced = feed.get('last_attempted') or feed.get('last_synced')
requested = feed.get('sync_requested_at')
try:
last = datetime.fromisoformat(last_synced) if last_synced else None
if last and last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
due = not last or last + timedelta(minutes=feed['sync_interval_minutes']) <= now
except (TypeError, ValueError):
due = True
if requested or due:
ics_sync.sync_feed(feed['id'])
def main():
database.configure(os.environ.get(
'DATABASE_PATH', os.path.join(os.path.dirname(__file__), 'calendario.db')
))
database.init_db()
lock_path = os.environ.get('SCHEDULER_LOCK_PATH', '/tmp/studycalendar-scheduler.lock')
lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
lock_file = os.fdopen(lock_fd, 'w', encoding='ascii')
try:
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise RuntimeError('Outro scheduler já está ativo neste host.') from exc
scheduler = BlockingScheduler(timezone='America/Sao_Paulo')
scheduler.add_job(sync_due_feeds, 'interval', minutes=1, id='sync-feeds', max_instances=1,
coalesce=True, next_run_time=datetime.now(timezone.utc))
logger.info('Scheduler dedicado iniciado.')
scheduler.start()
if __name__ == '__main__':
main()

245
security.py Normal file
View file

@ -0,0 +1,245 @@
import hmac
import ipaddress
import re
import secrets
import socket
import threading
import time
from collections import defaultdict, deque
from datetime import datetime
from urllib.parse import urlsplit
from flask import request, session
USERNAME_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{2,31}$")
COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
CATEGORIES = {"geral", "estudos", "trabalho", "pessoal", "provas", "entregas"}
PRIORITIES = {"baixa", "normal", "alta", "urgente"}
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
COMMON_PASSWORDS = {
"123456789012", "password1234", "qwerty123456", "senha12345678",
"admin12345678", "studycalendar",
}
class ValidationError(ValueError):
pass
def reject_unknown_fields(data, allowed):
unknown = set(data) - set(allowed)
if unknown:
raise ValidationError(f"Campos desconhecidos: {', '.join(sorted(unknown))}.")
def require_json():
if not request.is_json:
raise ValidationError("Content-Type application/json é obrigatório.")
data = request.get_json(silent=True)
if not isinstance(data, dict):
raise ValidationError("JSON deve ser um objeto.")
return data
def text(data, field, *, required=False, minimum=0, maximum=255, default=None):
value = data.get(field, default)
if value is None:
if required:
raise ValidationError(f"{field} é obrigatório.")
return None
if not isinstance(value, str):
raise ValidationError(f"{field} deve ser texto.")
value = value.strip()
if required and not value:
raise ValidationError(f"{field} é obrigatório.")
if len(value) < minimum or len(value) > maximum:
raise ValidationError(f"{field} deve ter entre {minimum} e {maximum} caracteres.")
return value
def integer(data, field, *, minimum, maximum, default=None):
value = data.get(field, default)
if isinstance(value, bool) or not isinstance(value, int):
raise ValidationError(f"{field} deve ser um número inteiro.")
if not minimum <= value <= maximum:
raise ValidationError(f"{field} deve estar entre {minimum} e {maximum}.")
return value
def boolean(data, field, default=False):
value = data.get(field, default)
if not isinstance(value, bool):
raise ValidationError(f"{field} deve ser booleano.")
return value
def iso_datetime(value, field, *, required=False):
if value in (None, ""):
if required:
raise ValidationError(f"{field} é obrigatório.")
return None
if not isinstance(value, str) or len(value) > 40:
raise ValidationError(f"{field} deve ser uma data ISO 8601 válida.")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValidationError(f"{field} deve ser uma data ISO 8601 válida.") from exc
return parsed.isoformat()
def validate_credentials(data, registering=False):
allowed = {"username", "password", "display_name"} if registering else {"username", "password"}
reject_unknown_fields(data, allowed)
username = text(data, "username", required=True, minimum=3, maximum=32).lower()
if not USERNAME_RE.fullmatch(username):
raise ValidationError("username aceita apenas letras minúsculas, números, ponto, hífen e sublinhado.")
password = data.get("password")
if not isinstance(password, str) or len(password) > 128:
raise ValidationError("password inválida.")
if registering and (len(password) < 12 or password.isspace()):
raise ValidationError("password deve ter entre 12 e 128 caracteres.")
if registering and password.casefold() in COMMON_PASSWORDS:
raise ValidationError("password é muito comum.")
if not registering and not password:
raise ValidationError("password é obrigatória.")
display_name = None
if registering:
display_name = text(data, "display_name", required=True, minimum=1, maximum=80)
if any(ord(char) < 32 for char in display_name):
raise ValidationError("display_name contém caracteres inválidos.")
return username, password, display_name
def validate_event(data):
reject_unknown_fields(data, {
"title", "start_datetime", "end_datetime", "description", "location",
"all_day", "category", "priority",
})
title = text(data, "title", required=True, minimum=1, maximum=200)
start = iso_datetime(data.get("start_datetime"), "start_datetime", required=True)
end = iso_datetime(data.get("end_datetime"), "end_datetime")
start_value = datetime.fromisoformat(start)
end_value = datetime.fromisoformat(end) if end else None
if end_value and bool(start_value.tzinfo) != bool(end_value.tzinfo):
raise ValidationError("start_datetime e end_datetime devem usar o mesmo tipo de fuso horário.")
if end_value and end_value <= start_value:
raise ValidationError("end_datetime deve ser posterior a start_datetime.")
category = data.get("category", "geral")
priority = data.get("priority", "normal")
if category not in CATEGORIES:
raise ValidationError("category inválida.")
if priority not in PRIORITIES:
raise ValidationError("priority inválida.")
return {
"title": title,
"start_datetime": start,
"end_datetime": end,
"description": text(data, "description", maximum=4000, default=""),
"location": text(data, "location", maximum=300, default=""),
"all_day": boolean(data, "all_day"),
"category": category,
"priority": priority,
}
def validate_feed(data, https_only=True):
reject_unknown_fields(data, {"url", "name", "color", "sync_interval_minutes"})
url = text(data, "url", required=True, minimum=8, maximum=2048)
parsed = urlsplit(url)
allowed = {"https"} if https_only else {"http", "https"}
if parsed.scheme.lower() not in allowed or not parsed.hostname or parsed.username or parsed.password:
raise ValidationError("URL de feed inválida ou protocolo não permitido.")
if parsed.fragment:
raise ValidationError("URL de feed não pode conter fragmento.")
color = data.get("color", "#4A90D9")
if not isinstance(color, str) or not COLOR_RE.fullmatch(color):
raise ValidationError("color deve usar o formato #RRGGBB.")
return {
"url": url,
"name": text(data, "name", required=True, minimum=1, maximum=100),
"color": color.upper(),
"sync_interval_minutes": integer(
data, "sync_interval_minutes", minimum=5, maximum=10080, default=30
),
}
def validate_public_url(url, https_only=True):
if not isinstance(url, str) or len(url) > 2048 or any(ord(char) < 32 for char in url):
raise ValidationError("URL de feed inválida.")
parsed = urlsplit(url)
allowed = {"https"} if https_only else {"http", "https"}
if parsed.scheme.lower() not in allowed or not parsed.hostname:
raise ValidationError("Protocolo de feed não permitido.")
if parsed.username or parsed.password or parsed.fragment:
raise ValidationError("URL de feed inválida.")
try:
port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
except ValueError as exc:
raise ValidationError("Porta inválida.") from exc
expected_port = 443 if parsed.scheme.lower() == "https" else 80
if port != expected_port:
raise ValidationError("Porta de feed não permitida.")
try:
records = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise ValidationError("Host do feed não pôde ser resolvido.") from exc
addresses = {
str(ipaddress.ip_address(record[4][0].split("%", 1)[0])) for record in records
}
if not addresses:
raise ValidationError("Host do feed não possui endereço válido.")
for address in addresses:
ip = ipaddress.ip_address(address)
if (not ip.is_global or ip.is_private or ip.is_reserved or ip.is_loopback
or ip.is_link_local or ip.is_multicast or ip.is_unspecified):
raise ValidationError("Host do feed aponta para uma rede não pública.")
return parsed, addresses
def csrf_token():
token = session.get("csrf_token")
if not token:
token = secrets.token_urlsafe(32)
session["csrf_token"] = token
return token
def valid_csrf(origin):
if request.method in SAFE_METHODS or not request.path.startswith("/api/"):
return True
supplied = request.headers.get("X-CSRF-Token", "")
expected = session.get("csrf_token", "")
if not supplied or not expected or not hmac.compare_digest(supplied, expected):
return False
source = request.headers.get("Origin") or request.headers.get("Referer")
if not source:
return True
source_url = urlsplit(source)
target_url = urlsplit(request.host_url)
return source_url.scheme == target_url.scheme and source_url.netloc == target_url.netloc
class RateLimiter:
def __init__(self):
self._hits = defaultdict(deque)
self._lock = threading.Lock()
def check(self, key, limit, window=60):
now = time.monotonic()
with self._lock:
if key not in self._hits and len(self._hits) >= 10000:
self._hits = defaultdict(deque, {
existing_key: hits for existing_key, hits in self._hits.items()
if hits and hits[-1] > now - window
})
if len(self._hits) >= 10000:
return False, window
hits = self._hits[key]
while hits and hits[0] <= now - window:
hits.popleft()
if len(hits) >= limit:
return False, max(1, int(window - (now - hits[0])))
hits.append(now)
return True, 0

13
test_feed.py Normal file
View file

@ -0,0 +1,13 @@
import ics_sync
import logging
logging.basicConfig(level=logging.DEBUG)
try:
print("Testing fetch...")
url = "https://salavirtual.unicep.edu.br/d2l/le/calendar/feed/user/feed.ics?token=a964bao41i3ctwr26ff8"
content = ics_sync.fetch_ics(url)
print("Content size:", len(content))
events = ics_sync.parse_ics(content)
print("Events parsed:", len(events))
except Exception as e:
import traceback
traceback.print_exc()

47
tests/conftest.py Normal file
View file

@ -0,0 +1,47 @@
import os
import sys
from pathlib import Path
import pytest
os.environ['APP_ENV'] = 'test'
os.environ['DATABASE_PATH'] = '/tmp/studycalendar-import-test.db'
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app import create_app # noqa: E402
@pytest.fixture
def app(tmp_path):
application = create_app({
'TESTING': True,
'SECRET_KEY': 't' * 64,
'DATABASE_PATH': str(tmp_path / 'test.db'),
'SESSION_COOKIE_SECURE': False,
'ENABLE_HSTS': True,
'RATELIMIT_ENABLED': False,
})
return application
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def origin(client):
token = client.get('/api/auth/csrf').get_json()['data']['csrf_token']
return {'Origin': 'http://localhost', 'X-CSRF-Token': token}
@pytest.fixture
def registered(client, origin):
response = client.post('/api/auth/register', json={
'username': 'student.one',
'password': 'correct horse battery staple',
'display_name': 'Student One',
}, headers=origin)
assert response.status_code == 200
return client

252
tests/test_api_security.py Normal file
View file

@ -0,0 +1,252 @@
import database
import pytest
import socket
from app import create_app
def event_payload(**changes):
payload = {
'title': 'Prova final',
'start_datetime': '2026-09-11T10:00:00',
'end_datetime': '2026-09-11T11:00:00',
'description': '',
'location': '',
'all_day': False,
'category': 'provas',
'priority': 'alta',
}
payload.update(changes)
return payload
def test_csrf_rejects_missing_and_cross_origin_but_accepts_token(client):
payload = {'username': 'student', 'password': 'a-secure-password', 'display_name': 'Student'}
assert client.post('/api/auth/register', json=payload).status_code == 403
assert client.post('/api/auth/register', json=payload,
headers={'Origin': 'http://localhost'}).status_code == 403
assert client.post('/api/auth/register', json=payload,
headers={'Origin': 'https://evil.example'}).status_code == 403
token = client.get('/api/auth/csrf').get_json()['data']['csrf_token']
assert client.post('/api/auth/register', json=payload, headers={
'Origin': 'https://evil.example', 'X-CSRF-Token': token
}).status_code == 403
response = client.post('/api/auth/register', json=payload, headers={'X-CSRF-Token': token})
assert response.status_code == 200
def test_auth_validation_and_session_rotation(client, origin):
weak = client.post('/api/auth/register', json={
'username': '../bad', 'password': 'short', 'display_name': 'X'
}, headers=origin)
assert weak.status_code == 400
common = client.post('/api/auth/register', json={
'username': 'common_user', 'password': 'password1234', 'display_name': 'X'
}, headers=origin)
assert common.status_code == 400
good = client.post('/api/auth/register', json={
'username': 'valid_user', 'password': 'long-enough-password', 'display_name': 'Valid User'
}, headers=origin)
assert good.status_code == 200
assert client.get('/api/auth/me').status_code == 200
assert client.post('/api/auth/logout', headers=origin).status_code == 200
assert client.get('/api/auth/me').status_code == 401
new_token = client.get('/api/auth/csrf').get_json()['data']['csrf_token']
assert client.post('/api/auth/login', json={
'username': 'valid_user', 'password': 'wrong-password'
}, headers={'Origin': 'http://localhost', 'X-CSRF-Token': new_token}).status_code == 401
def test_json_and_event_validation(registered, origin):
client = registered
assert client.post('/api/events', data='x', content_type='text/plain', headers=origin).status_code == 400
assert client.post('/api/events', json=event_payload(all_day='false'), headers=origin).status_code == 400
assert client.post('/api/events', json=event_payload(category='admin'), headers=origin).status_code == 400
assert client.post('/api/events', json=event_payload(priority='maximum'), headers=origin).status_code == 400
assert client.post('/api/events', json=event_payload(
end_datetime='2026-09-10T10:00:00'), headers=origin).status_code == 400
assert client.post('/api/events', json=event_payload(title='x' * 201), headers=origin).status_code == 400
assert client.get('/api/events?start=not-a-date').status_code == 400
assert client.post('/api/events', json=event_payload(admin=True), headers=origin).status_code == 400
def test_missing_and_foreign_resources_are_404(registered, origin):
client = registered
assert client.put('/api/events/999', json=event_payload(), headers=origin).status_code == 404
assert client.delete('/api/events/999', headers=origin).status_code == 404
assert client.patch('/api/events/999/toggle', headers=origin).status_code == 404
assert client.delete('/api/feeds/999', headers=origin).status_code == 404
assert client.delete('/api/notifications/rules/999', headers=origin).status_code == 404
def test_mark_notified_enforces_event_and_rule_ownership(app, registered, origin):
client = registered
other_id = database.create_user('other_user', 'another-long-password', 'Other')
event_id = database.add_event(other_id, 'Private', '2026-09-11T10:00:00')
other_rule = database.get_notification_rules(other_id)[0]['id']
own_rule = database.get_notification_rules(1)[0]['id']
assert client.post('/api/notifications/mark-notified', json={
'event_id': event_id, 'rule_id': other_rule
}, headers=origin).status_code == 404
assert client.post('/api/notifications/mark-notified', json={
'event_id': event_id, 'rule_id': own_rule
}, headers=origin).status_code == 404
def test_feed_and_rule_validation(registered, origin, monkeypatch):
client = registered
assert client.post('/api/feeds', json={
'name': 'Local', 'url': 'http://127.0.0.1/x.ics', 'color': 'red',
'sync_interval_minutes': 1
}, headers=origin).status_code == 400
assert client.post('/api/notifications/rules', json={
'minutes_before': True, 'label': 'bad'
}, headers=origin).status_code == 400
assert client.post('/api/notifications/rules', json={
'minutes_before': 20, 'label': 'x' * 101
}, headers=origin).status_code == 400
def test_feed_sync_is_queued_without_network(registered, origin, monkeypatch):
monkeypatch.setattr(socket, 'getaddrinfo', lambda _host, port, type: [
(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('93.184.216.34', port))
])
response = registered.post('/api/feeds', json={
'name': 'Calendar', 'url': 'https://calendar.example/feed.ics',
'color': '#123ABC', 'sync_interval_minutes': 30,
}, headers=origin)
assert response.status_code == 202
feed = database.get_feed(response.get_json()['data']['id'], 1)
assert feed['sync_requested_at']
def test_atomic_quotas_and_offset_queries(registered):
assert database.add_event(1, 'First', '2026-09-11T01:00:00-03:00', max_count=1)
assert database.add_event(1, 'Second', '2026-09-12T01:00:00-03:00', max_count=1) is None
result = database.get_events(
1, '2026-09-11T03:30:00+00:00', '2026-09-11T04:30:00+00:00'
)
assert [event['title'] for event in result] == ['First']
def test_security_headers_api_cache_and_no_cors(client):
response = client.get('/api/auth/csrf', headers={'Origin': 'https://evil.example'})
assert response.headers['X-Content-Type-Options'] == 'nosniff'
assert response.headers['X-Frame-Options'] == 'DENY'
assert response.headers['Referrer-Policy'] == 'no-referrer'
assert 'frame-ancestors' in response.headers['Content-Security-Policy']
assert response.headers['Strict-Transport-Security'].startswith('max-age=')
assert response.headers['Cache-Control'].startswith('no-store')
assert 'Access-Control-Allow-Origin' not in response.headers
def test_body_limit(client, origin):
response = client.post('/api/auth/login', data=b'x' * 70000,
content_type='application/json', headers=origin)
assert response.status_code == 413
def test_production_requires_strong_secret(monkeypatch):
monkeypatch.setenv('APP_ENV', 'production')
monkeypatch.setenv('FLASK_SECRET_KEY', 'short')
monkeypatch.setenv('TRUSTED_HOSTS', 'localhost')
try:
create_app()
except RuntimeError as error:
assert 'FLASK_SECRET_KEY' in str(error)
else:
raise AssertionError('produção aceitou chave fraca')
def test_production_disables_public_registration(tmp_path, monkeypatch):
monkeypatch.setenv('APP_ENV', 'production')
monkeypatch.setenv('FLASK_SECRET_KEY', 'production-key-0123456789-ABCDEFGHIJKLMNOPQRSTUVWXYZ')
monkeypatch.setenv('ALLOW_REGISTRATION', 'false')
application = create_app({
'TESTING': True,
'DATABASE_PATH': str(tmp_path / 'registration.db'),
'SESSION_COOKIE_SECURE': True,
'RATELIMIT_ENABLED': False,
'TRUSTED_HOSTS': ['localhost'],
})
production_client = application.test_client()
token = production_client.get('/api/auth/csrf').get_json()['data']
assert token['registration_enabled'] is False
response = production_client.post('/api/auth/register', json={
'username': 'blocked_user',
'password': 'long-enough-password',
'display_name': 'Blocked',
}, headers={'X-CSRF-Token': token['csrf_token']})
assert response.status_code == 403
def test_logout_revokes_other_session(app, registered, origin):
second = app.test_client()
second_token = second.get('/api/auth/csrf').get_json()['data']['csrf_token']
login = second.post('/api/auth/login', json={
'username': 'student.one',
'password': 'correct horse battery staple',
}, headers={'X-CSRF-Token': second_token})
assert login.status_code == 200
assert second.get('/api/auth/me').status_code == 200
assert registered.post('/api/auth/logout', headers=origin).status_code == 200
assert second.get('/api/auth/me').status_code == 401
def test_feed_secrets_are_hidden_and_color_reaches_events(registered):
user_id = 1
feed_id = database.add_feed(user_id, 'https://calendar.example/x?token=secret', 'Private', '#123ABC')
database.replace_ics_events(feed_id, user_id, [{
'uid': 'event-1',
'title': 'Imported',
'start_datetime': '2026-09-11T10:00:00',
'end_datetime': '2026-09-11T11:00:00',
'description': '',
'location': '',
'all_day': False,
}])
feeds = registered.get('/api/feeds').get_json()['data']
assert 'url' not in feeds[0]
events = registered.get('/api/events').get_json()['data']
assert events[0]['feed_color'] == '#123ABC'
def test_secure_cookie_attributes(tmp_path, monkeypatch, origin):
monkeypatch.setenv('APP_ENV', 'production')
monkeypatch.setenv('FLASK_SECRET_KEY', 'secure-cookie-test-key-0123456789-ABCDEFGHIJKLMNOPQRSTUVWXYZ')
monkeypatch.setenv('SESSION_COOKIE_SECURE', 'true')
application = create_app({
'TESTING': True, 'DATABASE_PATH': str(tmp_path / 'cookie.db'),
'RATELIMIT_ENABLED': False,
'TRUSTED_HOSTS': ['localhost'],
})
response = application.test_client().get('/api/auth/csrf')
cookie = response.headers['Set-Cookie']
assert 'Secure' in cookie
assert 'HttpOnly' in cookie
assert 'SameSite=Lax' in cookie
def test_production_rejects_insecure_cookie(tmp_path, monkeypatch):
monkeypatch.setenv('APP_ENV', 'production')
monkeypatch.setenv('FLASK_SECRET_KEY', 'secure-cookie-test-key-0123456789-ABCDEFGHIJKLMNOPQRSTUVWXYZ')
monkeypatch.setenv('TRUSTED_HOSTS', 'localhost')
monkeypatch.setenv('SESSION_COOKIE_SECURE', 'false')
with pytest.raises(RuntimeError, match='SESSION_COOKIE_SECURE'):
create_app({'DATABASE_PATH': str(tmp_path / 'insecure-cookie.db')})
def test_auth_rate_limit(tmp_path, monkeypatch, origin):
monkeypatch.setenv('APP_ENV', 'test')
application = create_app({
'TESTING': True, 'SECRET_KEY': 'r' * 64,
'DATABASE_PATH': str(tmp_path / 'rate.db'), 'SESSION_COOKIE_SECURE': False,
})
rate_client = application.test_client()
token = rate_client.get('/api/auth/csrf').get_json()['data']['csrf_token']
rate_headers = {'Origin': 'http://localhost', 'X-CSRF-Token': token}
payload = {'username': 'nobody', 'password': 'wrong-password'}
for _ in range(10):
assert rate_client.post('/api/auth/login', json=payload, headers=rate_headers).status_code == 401
response = rate_client.post('/api/auth/login', json=payload, headers=rate_headers)
assert response.status_code == 429
assert int(response.headers['Retry-After']) >= 1

View file

@ -0,0 +1,51 @@
import sqlite3
import database
def test_legacy_notification_schema_is_migrated(tmp_path):
path = tmp_path / 'legacy.db'
connection = sqlite3.connect(path)
connection.executescript('''
CREATE TABLE users (
id INTEGER PRIMARY KEY, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, display_name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE feeds (
id INTEGER PRIMARY KEY, url TEXT UNIQUE NOT NULL, name TEXT NOT NULL,
color TEXT, sync_interval_minutes INTEGER, last_synced TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, user_id INTEGER DEFAULT 1
);
CREATE TABLE events (
id INTEGER PRIMARY KEY, feed_id INTEGER, uid TEXT, title TEXT NOT NULL,
description TEXT, location TEXT, start_datetime TIMESTAMP NOT NULL,
end_datetime TIMESTAMP, all_day BOOLEAN DEFAULT 0, category TEXT,
priority TEXT, completed BOOLEAN DEFAULT 0, source TEXT,
created_at TIMESTAMP, updated_at TIMESTAMP, user_id INTEGER DEFAULT 1
);
CREATE TABLE notification_rules (
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL,
minutes_before INTEGER NOT NULL, label TEXT NOT NULL, enabled BOOLEAN DEFAULT 1
);
CREATE TABLE notification_log (
id INTEGER PRIMARY KEY, event_id INTEGER, notified_at TIMESTAMP,
notify_type TEXT
);
''')
connection.close()
database.configure(str(path))
database.init_db()
connection = sqlite3.connect(path)
user_columns = {row[1] for row in connection.execute('PRAGMA table_info(users)')}
feed_columns = {row[1] for row in connection.execute('PRAGMA table_info(feeds)')}
log_columns = {row[1] for row in connection.execute('PRAGMA table_info(notification_log)')}
version = connection.execute('PRAGMA user_version').fetchone()[0]
connection.close()
assert 'session_version' in user_columns
assert 'sync_requested_at' in feed_columns
assert 'last_attempted' in feed_columns
assert {'event_id', 'rule_id', 'notified_at'} <= log_columns
assert version == 3

View file

@ -0,0 +1,47 @@
from html.parser import HTMLParser
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class MarkupAudit(HTMLParser):
def __init__(self):
super().__init__()
self.ids = []
self.inline_handlers = []
self.inline_scripts = 0
self.external_assets = []
def handle_starttag(self, tag, attrs):
attributes = dict(attrs)
if attributes.get('id'):
self.ids.append(attributes['id'])
self.inline_handlers.extend(name for name, _value in attrs if name.startswith('on'))
if tag == 'script' and not attributes.get('src'):
self.inline_scripts += 1
for name in ('src', 'href'):
value = attributes.get(name, '')
if value.startswith(('http://', 'https://')):
self.external_assets.append(value)
def test_markup_has_unique_ids_and_no_inline_or_external_code():
parser = MarkupAudit()
parser.feed((ROOT / 'templates/index.html').read_text(encoding='utf-8'))
assert len(parser.ids) == len(set(parser.ids))
assert parser.inline_handlers == []
assert parser.inline_scripts == 0
assert parser.external_assets == []
def test_javascript_avoids_dangerous_html_sinks():
source = (ROOT / 'static/js/app.js').read_text(encoding='utf-8')
for sink in ('innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval('):
assert sink not in source
def test_viewport_allows_zoom():
markup = (ROOT / 'templates/index.html').read_text(encoding='utf-8')
assert 'user-scalable=no' not in markup
assert 'maximum-scale=' not in markup

219
tests/test_ics_security.py Normal file
View file

@ -0,0 +1,219 @@
import socket
import pytest
import requests
import ics_sync
import database
from security import ValidationError, validate_public_url
def public_dns(_host, port, type):
assert type == socket.SOCK_STREAM
return [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('93.184.216.34', port))]
@pytest.mark.parametrize('address', [
'127.0.0.1', '10.0.0.1', '169.254.169.254', '224.0.0.1', '0.0.0.0',
'::1', 'fc00::1', 'fe80::1', 'ff00::1', '::',
])
def test_private_and_reserved_addresses_are_blocked(monkeypatch, address):
family = socket.AF_INET6 if ':' in address else socket.AF_INET
monkeypatch.setattr(socket, 'getaddrinfo', lambda *_args, **_kwargs: [
(family, socket.SOCK_STREAM, 6, '', (address, 443))
])
with pytest.raises(ValidationError):
validate_public_url('https://calendar.example/feed.ics')
def test_protocol_credentials_and_fragments_are_blocked(monkeypatch):
monkeypatch.setattr(socket, 'getaddrinfo', public_dns)
for url in ['http://example.com/x', 'file:///etc/passwd',
'https://user:token@example.com/x', 'https://example.com/x#secret']:
with pytest.raises(ValidationError):
validate_public_url(url)
with pytest.raises(ValidationError):
validate_public_url('https://example.com:8443/x')
class FakeSocket:
def getpeername(self):
return ('93.184.216.34', 443)
class FakeConnection:
sock = FakeSocket()
class FakeRaw:
_connection = FakeConnection()
class FakeResponse:
def __init__(self, status=200, headers=None, chunks=None):
self.status_code = status
self.headers = headers or {}
self._chunks = chunks or [b'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n']
self.raw = FakeRaw()
self.is_redirect = status in {301, 302, 303, 307, 308}
self.is_permanent_redirect = status in {308}
def iter_content(self, _size):
yield from self._chunks
def raise_for_status(self):
if self.status_code >= 400:
raise requests.HTTPError('failure containing https://secret.example/token')
def close(self):
pass
class FakeSession:
responses = []
instances = []
def __init__(self):
self.trust_env = True
self.calls = []
self.instances.append(self)
def get(self, url, **kwargs):
self.calls.append((url, kwargs))
return self.responses.pop(0)
def close(self):
pass
class ResolvingSession(FakeSession):
connected_address = None
hostname = 'calendar.example'
def get(self, url, **kwargs):
self.connected_address = socket.getaddrinfo(
self.hostname, 443, type=socket.SOCK_STREAM
)[0][4][0]
return super().get(url, **kwargs)
def test_fetch_disables_environment_proxy_and_verifies_tls(monkeypatch):
monkeypatch.setattr(socket, 'getaddrinfo', public_dns)
FakeSession.responses = [FakeResponse()]
result = ics_sync.fetch_ics('https://calendar.example/x', FakeSession)
instance = FakeSession.instances[-1]
assert result.startswith(b'BEGIN:VCALENDAR')
assert instance.trust_env is False
assert instance.calls[0][1]['verify'] is True
assert instance.calls[0][1]['allow_redirects'] is False
assert instance.calls[0][1]['stream'] is True
def test_every_redirect_is_revalidated(monkeypatch):
def dns(host, port, type):
address = '127.0.0.1' if host == 'internal.example' else '93.184.216.34'
return [(socket.AF_INET, socket.SOCK_STREAM, 6, '', (address, port))]
monkeypatch.setattr(socket, 'getaddrinfo', dns)
FakeSession.responses = [FakeResponse(302, {'Location': 'https://internal.example/secret'})]
with pytest.raises(ValidationError):
ics_sync.fetch_ics('https://calendar.example/x', FakeSession)
def test_download_size_is_limited(monkeypatch):
monkeypatch.setattr(socket, 'getaddrinfo', public_dns)
monkeypatch.setattr(ics_sync, 'MAX_BYTES', 5)
FakeSession.responses = [FakeResponse(chunks=[b'123', b'456'])]
with pytest.raises(ValidationError):
ics_sync.fetch_ics('https://calendar.example/x', FakeSession)
def test_fetch_rejects_when_peer_cannot_be_verified(monkeypatch):
monkeypatch.setattr(socket, 'getaddrinfo', public_dns)
response = FakeResponse()
response.raw = object()
FakeSession.responses = [response]
with pytest.raises(ValidationError):
ics_sync.fetch_ics('https://calendar.example/x', FakeSession)
def test_dns_is_pinned_before_request(monkeypatch):
calls = 0
def rebinding_dns(_host, port, type):
nonlocal calls
calls += 1
address = '93.184.216.34' if calls == 1 else '127.0.0.1'
return [(socket.AF_INET, socket.SOCK_STREAM, 6, '', (address, port))]
monkeypatch.setattr(socket, 'getaddrinfo', rebinding_dns)
ResolvingSession.responses = [FakeResponse()]
ics_sync.fetch_ics('https://calendar.example/x', ResolvingSession)
assert ResolvingSession.instances[-1].connected_address == '93.184.216.34'
assert calls == 1
def test_idn_hostname_is_pinned_after_punycode_conversion(monkeypatch):
calls = 0
def rebinding_dns(_host, port, type):
nonlocal calls
calls += 1
address = '93.184.216.34' if calls == 1 else '127.0.0.1'
return [(socket.AF_INET, socket.SOCK_STREAM, 6, '', (address, port))]
monkeypatch.setattr(socket, 'getaddrinfo', rebinding_dns)
ResolvingSession.hostname = 'xn--tst-qla.example'
ResolvingSession.responses = [FakeResponse()]
try:
ics_sync.fetch_ics('https://täst.example/x', ResolvingSession)
finally:
ResolvingSession.hostname = 'calendar.example'
assert ResolvingSession.instances[-1].connected_address == '93.184.216.34'
assert calls == 1
def test_parser_limits_events_and_fields(monkeypatch):
monkeypatch.setattr(ics_sync, 'MAX_EVENTS', 1)
content = b'''BEGIN:VCALENDAR\r
VERSION:2.0\r
BEGIN:VEVENT\r
UID:one\r
DTSTART:20260911T100000Z\r
SUMMARY:First\r
END:VEVENT\r
BEGIN:VEVENT\r
UID:two\r
DTSTART:20260912T100000Z\r
SUMMARY:Second\r
END:VEVENT\r
END:VCALENDAR\r
'''
with pytest.raises(ValidationError):
ics_sync.parse_ics(content)
def test_parser_truncates_untrusted_fields():
summary = 'x' * 300
content = f'''BEGIN:VCALENDAR\r
VERSION:2.0\r
BEGIN:VEVENT\r
UID:one\r
DTSTART:20260911T100000Z\r
SUMMARY:{summary}\r
END:VEVENT\r
END:VCALENDAR\r
'''.encode()
assert len(ics_sync.parse_ics(content)[0]['title']) == 200
def test_sync_never_logs_feed_url_or_token(app, monkeypatch, caplog):
user_id = database.create_user('feed_owner', 'another-long-password', 'Feed Owner')
secret_url = 'https://calendar.example/private.ics?token=top-secret'
feed_id = database.add_feed(user_id, secret_url, 'Private')
monkeypatch.setattr(ics_sync, 'fetch_ics', lambda _url: (_ for _ in ()).throw(
requests.RequestException(f'failed for {secret_url}')
))
assert ics_sync.sync_feed(feed_id) is False
assert secret_url not in caplog.text
assert 'top-secret' not in caplog.text