Deploy URAAS — African Research Archival & Analytics System
Browse files- docs/PARTNER_API.md +174 -0
- scripts/manage_api_keys.py +117 -0
- scripts/push_to_hf.py +28 -5
- uraas/dashboard/app.py +153 -0
- uraas/database.py +24 -0
docs/PARTNER_API.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# URAAS Partner API
|
| 2 |
+
|
| 3 |
+
Server-to-server read access to URAAS (University of Lagos Academic Archive &
|
| 4 |
+
Special Collections) data — built for external partners like the Africa PID
|
| 5 |
+
Alliance / DOCiD to integrate against, without needing a browser session.
|
| 6 |
+
|
| 7 |
+
This is separate from the dashboard's human login (username/password +
|
| 8 |
+
session cookie). Partner access uses a long-lived API key instead.
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## Base URL
|
| 13 |
+
|
| 14 |
+
```
|
| 15 |
+
https://lordkiki-apa-uraas.hf.space
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
## Authentication
|
| 19 |
+
|
| 20 |
+
Every request must include the key in a header:
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
X-API-Key: uraas_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
- Missing or invalid key → `401 Unauthorized`
|
| 27 |
+
- Valid key used against an endpoint outside this document → `403 Forbidden`
|
| 28 |
+
(partner keys are read-only by construction — no key can reach crawler
|
| 29 |
+
controls, admin exports, or anything that mutates data, regardless of what
|
| 30 |
+
path is requested)
|
| 31 |
+
- Keys are issued manually by a URAAS admin. To request one, contact the
|
| 32 |
+
URAAS team directly — there is no self-service signup.
|
| 33 |
+
|
| 34 |
+
## Rate limits
|
| 35 |
+
|
| 36 |
+
**120 requests per rolling 60-second window, per key.** Exceeding it returns
|
| 37 |
+
`429 Too Many Requests`. Contact us if your integration needs a higher limit.
|
| 38 |
+
|
| 39 |
+
## Errors
|
| 40 |
+
|
| 41 |
+
All errors are JSON:
|
| 42 |
+
|
| 43 |
+
```json
|
| 44 |
+
{ "status": "error", "message": "..." }
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
| Status | Meaning |
|
| 48 |
+
|---|---|
|
| 49 |
+
| `401` | Missing or invalid/revoked API key |
|
| 50 |
+
| `403` | Valid key, but this endpoint isn't in the partner allowlist |
|
| 51 |
+
| `429` | Rate limit exceeded |
|
| 52 |
+
| `404` | Resource not found (e.g. unknown paper id) |
|
| 53 |
+
| `500` | Server error — please report it to us |
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## Endpoints
|
| 58 |
+
|
| 59 |
+
### `GET /api/stats`
|
| 60 |
+
Network/collaboration summary: co-authorship edges between departments, and
|
| 61 |
+
top authors by paper count.
|
| 62 |
+
|
| 63 |
+
```json
|
| 64 |
+
{
|
| 65 |
+
"status": "success",
|
| 66 |
+
"network_edges": [{ "source": "Botany", "target": "Chemistry", "weight": 1 }],
|
| 67 |
+
"top_authors": [{ "author": "...", "count": 2, "orcid": "", "ror": "" }]
|
| 68 |
+
}
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
### `GET /api/papers/tree`
|
| 72 |
+
Every paper, grouped by faculty then department.
|
| 73 |
+
|
| 74 |
+
```json
|
| 75 |
+
{
|
| 76 |
+
"data": {
|
| 77 |
+
"College of Medicine": {
|
| 78 |
+
"Community Health and Primary Care": [
|
| 79 |
+
{
|
| 80 |
+
"id": 22,
|
| 81 |
+
"title": "...",
|
| 82 |
+
"doi": "10.1093/hmg/ddw104",
|
| 83 |
+
"url": "https://doi.org/10.1093/hmg/ddw104",
|
| 84 |
+
"docid": "",
|
| 85 |
+
"download_url": null,
|
| 86 |
+
"has_local_pdf": false,
|
| 87 |
+
"access_policy": null
|
| 88 |
+
}
|
| 89 |
+
]
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
```
|
| 94 |
+
`docid` is populated once a paper has a real, registered DOCiD — empty string
|
| 95 |
+
otherwise. This is the field to poll if you want to detect which URAAS papers
|
| 96 |
+
already carry a DOCiD vs. which don't yet.
|
| 97 |
+
|
| 98 |
+
### `GET /api/papers/<id>`
|
| 99 |
+
Full metadata for one paper — title, abstract, authors, DOI, publication
|
| 100 |
+
date, Special Collections category/score, and identifiers (`docid`, `ark`,
|
| 101 |
+
`ror`) where present.
|
| 102 |
+
|
| 103 |
+
### `GET /api/papers/<id>/download`
|
| 104 |
+
Redirects to or streams the PDF, only when the item is open access.
|
| 105 |
+
|
| 106 |
+
### `GET /api/papers/<id>/bibtex`
|
| 107 |
+
BibTeX citation export for one paper (`Content-Type: text/plain`).
|
| 108 |
+
|
| 109 |
+
### `GET /api/analytics/special-collections`
|
| 110 |
+
The Special Collections subset specifically — the papers classified into one
|
| 111 |
+
or more of the 8 categories (Indigenous Knowledge, Cultural Heritage, Oral
|
| 112 |
+
Tradition, Ethnomusicology, African Philosophy, Traditional Medicine, Ethnic
|
| 113 |
+
Languages & Groups, Pan-African Studies).
|
| 114 |
+
|
| 115 |
+
### `GET /api/analytics/special-collections/overview`
|
| 116 |
+
Aggregate view over that same set: category co-occurrence matrix, country
|
| 117 |
+
breakdown, custodian institutions, and the most-cited/influential papers per
|
| 118 |
+
category.
|
| 119 |
+
|
| 120 |
+
```json
|
| 121 |
+
{
|
| 122 |
+
"co_occurrence": { "labels": [...], "matrix": [[...]] },
|
| 123 |
+
"countries": [{ "code": "NG", "name": "Nigeria", "papers": 54 }],
|
| 124 |
+
"custodians": [{ "institution": "University of Lagos", "count": 54 }],
|
| 125 |
+
"influential": [{ "id": 1, "title": "...", "categories": [...], "citations": 856, "sc_score": 3.0 }]
|
| 126 |
+
}
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
### `GET /api/university-registry`
|
| 130 |
+
The full registry of institutions URAAS crawls against (52 African
|
| 131 |
+
universities as of this writing) — name, ROR, country.
|
| 132 |
+
|
| 133 |
+
### `GET /api/institution/info?institution=unilag`
|
| 134 |
+
Summary for one institution from the registry above. `institution` is the
|
| 135 |
+
registry key (e.g. `unilag`); omitting it returns null fields.
|
| 136 |
+
|
| 137 |
+
```json
|
| 138 |
+
{ "status": "success", "data": { "institution_name": "University of Lagos", "country_name": "Nigeria", "country_code": "NG" } }
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
## Example
|
| 144 |
+
|
| 145 |
+
```bash
|
| 146 |
+
curl -H "X-API-Key: uraas_live_..." \
|
| 147 |
+
"https://lordkiki-apa-uraas.hf.space/api/analytics/special-collections/overview"
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
## What this API does *not* do (yet)
|
| 153 |
+
|
| 154 |
+
There is currently no inbound path — nothing lets DOCID's platform push data
|
| 155 |
+
or notifications back into URAAS (e.g. "here's the DocID we just assigned,
|
| 156 |
+
sync your record" or a webhook on registration). Every endpoint above is
|
| 157 |
+
URAAS serving data out. If two-way sync is needed, that's new work to scope
|
| 158 |
+
separately, not something already exposed here.
|
| 159 |
+
|
| 160 |
+
## For URAAS admins — issuing/revoking keys
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
python scripts/manage_api_keys.py create --name "Africa PID Alliance / DOCiD"
|
| 164 |
+
python scripts/manage_api_keys.py list
|
| 165 |
+
python scripts/manage_api_keys.py revoke --prefix uraas_live_AbCd1234
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
The full key value is shown exactly once, at creation — only its hash is
|
| 169 |
+
stored. If a key is lost or compromised, revoke it and issue a new one; there
|
| 170 |
+
is no way to recover a lost key's value.
|
| 171 |
+
|
| 172 |
+
To add a new endpoint to partner access, add its Flask endpoint (function)
|
| 173 |
+
name to `PARTNER_ENDPOINTS` in `uraas/dashboard/app.py` — nothing is reachable
|
| 174 |
+
via API key unless explicitly listed there.
|
scripts/manage_api_keys.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Manage API keys for external partner access to the URAAS dashboard API
|
| 2 |
+
(e.g. Africa PID Alliance / DOCiD reading papers/analytics endpoints
|
| 3 |
+
server-to-server, instead of using the browser session-cookie login).
|
| 4 |
+
|
| 5 |
+
The plaintext key is shown exactly once, at creation — it is never stored;
|
| 6 |
+
only its SHA-256 hash is, so a stolen database dump can't be used to
|
| 7 |
+
authenticate as a partner. If a key is lost, revoke it and create a new one.
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
python scripts/manage_api_keys.py create --name "Africa PID Alliance / DOCiD"
|
| 11 |
+
python scripts/manage_api_keys.py list
|
| 12 |
+
python scripts/manage_api_keys.py revoke --prefix uraas_live_AbCd
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import hashlib
|
| 17 |
+
import os
|
| 18 |
+
import secrets
|
| 19 |
+
import sys
|
| 20 |
+
from datetime import datetime
|
| 21 |
+
|
| 22 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 23 |
+
|
| 24 |
+
from uraas.database import ApiKey, SessionLocal
|
| 25 |
+
|
| 26 |
+
_PREFIX = "uraas_live_"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _hash(key: str) -> str:
|
| 30 |
+
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def cmd_create(args):
|
| 34 |
+
raw = _PREFIX + secrets.token_urlsafe(32)
|
| 35 |
+
session = SessionLocal()
|
| 36 |
+
try:
|
| 37 |
+
row = ApiKey(
|
| 38 |
+
name=args.name,
|
| 39 |
+
key_hash=_hash(raw),
|
| 40 |
+
key_prefix=raw[: len(_PREFIX) + 8],
|
| 41 |
+
scope="read",
|
| 42 |
+
created_by=args.created_by or "cli",
|
| 43 |
+
)
|
| 44 |
+
session.add(row)
|
| 45 |
+
session.commit()
|
| 46 |
+
print("Created API key — this is the ONLY time the full value is shown:\n")
|
| 47 |
+
print(f" {raw}\n")
|
| 48 |
+
print(f"Name: {row.name}")
|
| 49 |
+
print(f"Prefix: {row.key_prefix} (safe to log/reference later)")
|
| 50 |
+
print(f"Scope: {row.scope}")
|
| 51 |
+
print("\nGive the partner this header format:")
|
| 52 |
+
print(f' X-API-Key: {raw}')
|
| 53 |
+
finally:
|
| 54 |
+
session.close()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def cmd_list(args):
|
| 58 |
+
session = SessionLocal()
|
| 59 |
+
try:
|
| 60 |
+
rows = session.query(ApiKey).order_by(ApiKey.created_at.desc()).all()
|
| 61 |
+
if not rows:
|
| 62 |
+
print("No API keys yet.")
|
| 63 |
+
return
|
| 64 |
+
for r in rows:
|
| 65 |
+
status = "REVOKED" if r.revoked else "active"
|
| 66 |
+
last_used = r.last_used_at.isoformat() if r.last_used_at else "never"
|
| 67 |
+
print(f"[{r.id}] {r.key_prefix}... {r.name!r} scope={r.scope} "
|
| 68 |
+
f"status={status} created={r.created_at} last_used={last_used}")
|
| 69 |
+
finally:
|
| 70 |
+
session.close()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def cmd_revoke(args):
|
| 74 |
+
session = SessionLocal()
|
| 75 |
+
try:
|
| 76 |
+
q = session.query(ApiKey)
|
| 77 |
+
row = None
|
| 78 |
+
if args.id:
|
| 79 |
+
row = q.filter(ApiKey.id == args.id).first()
|
| 80 |
+
elif args.prefix:
|
| 81 |
+
row = q.filter(ApiKey.key_prefix == args.prefix).first()
|
| 82 |
+
if not row:
|
| 83 |
+
print("No matching key found.")
|
| 84 |
+
return
|
| 85 |
+
row.revoked = True
|
| 86 |
+
row.revoked_at = datetime.utcnow()
|
| 87 |
+
session.commit()
|
| 88 |
+
print(f"Revoked key [{row.id}] {row.key_prefix}... ({row.name!r})")
|
| 89 |
+
finally:
|
| 90 |
+
session.close()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def main():
|
| 94 |
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 95 |
+
sub = parser.add_subparsers(dest="command", required=True)
|
| 96 |
+
|
| 97 |
+
p_create = sub.add_parser("create", help="Issue a new partner API key")
|
| 98 |
+
p_create.add_argument("--name", required=True, help="Partner label, e.g. 'Africa PID Alliance / DOCiD'")
|
| 99 |
+
p_create.add_argument("--created-by", default=None)
|
| 100 |
+
p_create.set_defaults(func=cmd_create)
|
| 101 |
+
|
| 102 |
+
p_list = sub.add_parser("list", help="List all keys (never shows the full value)")
|
| 103 |
+
p_list.set_defaults(func=cmd_list)
|
| 104 |
+
|
| 105 |
+
p_revoke = sub.add_parser("revoke", help="Revoke a key")
|
| 106 |
+
p_revoke.add_argument("--id", type=int, default=None)
|
| 107 |
+
p_revoke.add_argument("--prefix", default=None)
|
| 108 |
+
p_revoke.set_defaults(func=cmd_revoke)
|
| 109 |
+
|
| 110 |
+
args = parser.parse_args()
|
| 111 |
+
if args.command == "revoke" and not (args.id or args.prefix):
|
| 112 |
+
parser.error("revoke requires --id or --prefix")
|
| 113 |
+
args.func(args)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
if __name__ == "__main__":
|
| 117 |
+
main()
|
scripts/push_to_hf.py
CHANGED
|
@@ -123,16 +123,39 @@ def main():
|
|
| 123 |
print(_SEP)
|
| 124 |
|
| 125 |
# ── Auth ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
token = os.getenv("HF_TOKEN")
|
| 127 |
if token:
|
| 128 |
login(token=token, add_to_git_credential=True)
|
| 129 |
print(" Logged in via HF_TOKEN env var.")
|
| 130 |
else:
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
api = HfApi()
|
| 138 |
|
|
|
|
| 123 |
print(_SEP)
|
| 124 |
|
| 125 |
# ── Auth ───────────────────────────────────────────────────────────────
|
| 126 |
+
# Live-verified 2026-08-04: with no HF_TOKEN env var, this used to always
|
| 127 |
+
# call login() with no token, which prompts interactively for one — in
|
| 128 |
+
# any non-interactive shell (CI, a background task, this script run
|
| 129 |
+
# without a TTY) that blocks forever waiting for input that can never
|
| 130 |
+
# arrive, even when a valid token is already cached from a previous
|
| 131 |
+
# `huggingface-cli login` on the same machine. Try the cached credential
|
| 132 |
+
# first; only fall back to an interactive prompt when actually attached
|
| 133 |
+
# to a terminal.
|
| 134 |
token = os.getenv("HF_TOKEN")
|
| 135 |
if token:
|
| 136 |
login(token=token, add_to_git_credential=True)
|
| 137 |
print(" Logged in via HF_TOKEN env var.")
|
| 138 |
else:
|
| 139 |
+
from huggingface_hub import HfFolder
|
| 140 |
+
cached = HfFolder.get_token()
|
| 141 |
+
if cached:
|
| 142 |
+
try:
|
| 143 |
+
HfApi().whoami(token=cached)
|
| 144 |
+
print(" Logged in via cached HF credential.")
|
| 145 |
+
except Exception:
|
| 146 |
+
cached = None
|
| 147 |
+
if not cached:
|
| 148 |
+
if not sys.stdin.isatty():
|
| 149 |
+
print()
|
| 150 |
+
print(" No HF_TOKEN env var, no cached credential, and no terminal to prompt on.")
|
| 151 |
+
print(" Set HF_TOKEN=hf_... and re-run, e.g.:")
|
| 152 |
+
print(f" HF_TOKEN=hf_... python {os.path.basename(__file__)}")
|
| 153 |
+
sys.exit(1)
|
| 154 |
+
print()
|
| 155 |
+
print(" Paste your HF write token below.")
|
| 156 |
+
print(" (Get one at: https://huggingface.co/settings/tokens)")
|
| 157 |
+
print()
|
| 158 |
+
login(add_to_git_credential=True)
|
| 159 |
|
| 160 |
api = HfApi()
|
| 161 |
|
uraas/dashboard/app.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
| 1 |
import csv
|
|
|
|
| 2 |
import io
|
| 3 |
import json
|
| 4 |
import logging
|
| 5 |
import os
|
| 6 |
import re
|
|
|
|
| 7 |
import subprocess
|
| 8 |
import threading
|
|
|
|
|
|
|
| 9 |
|
| 10 |
from flask import (
|
| 11 |
Flask,
|
|
@@ -25,6 +29,7 @@ from sqlalchemy.orm import selectinload
|
|
| 25 |
from uraas.analytics.engine import analytics
|
| 26 |
from uraas.config import config
|
| 27 |
from uraas.database import (
|
|
|
|
| 28 |
Author,
|
| 29 |
Collection,
|
| 30 |
Community,
|
|
@@ -109,8 +114,71 @@ ADMIN_ENDPOINTS = {
|
|
| 109 |
"citations_velocity_csv",
|
| 110 |
"staff_directory",
|
| 111 |
"clear_half_and_recrawl",
|
|
|
|
|
|
|
|
|
|
| 112 |
}
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
@app.before_request
|
| 116 |
def _enforce_authentication():
|
|
@@ -118,6 +186,18 @@ def _enforce_authentication():
|
|
| 118 |
# Unknown endpoint (404s) and explicit public routes pass through.
|
| 119 |
if endpoint is None or endpoint in PUBLIC_ENDPOINTS:
|
| 120 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
role = current_role()
|
| 122 |
if not role:
|
| 123 |
if request.path.startswith("/api/"):
|
|
@@ -1827,6 +1907,79 @@ def clear_half_and_recrawl():
|
|
| 1827 |
)
|
| 1828 |
|
| 1829 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1830 |
@app.route("/api/author/<int:author_id>/metrics")
|
| 1831 |
def get_author_metrics(author_id):
|
| 1832 |
"""Get bibliometric indicators for an author (h-index, citations, etc.)."""
|
|
|
|
| 1 |
import csv
|
| 2 |
+
import hashlib
|
| 3 |
import io
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
import os
|
| 7 |
import re
|
| 8 |
+
import secrets
|
| 9 |
import subprocess
|
| 10 |
import threading
|
| 11 |
+
import time
|
| 12 |
+
from datetime import datetime
|
| 13 |
|
| 14 |
from flask import (
|
| 15 |
Flask,
|
|
|
|
| 29 |
from uraas.analytics.engine import analytics
|
| 30 |
from uraas.config import config
|
| 31 |
from uraas.database import (
|
| 32 |
+
ApiKey,
|
| 33 |
Author,
|
| 34 |
Collection,
|
| 35 |
Community,
|
|
|
|
| 114 |
"citations_velocity_csv",
|
| 115 |
"staff_directory",
|
| 116 |
"clear_half_and_recrawl",
|
| 117 |
+
"admin_list_api_keys",
|
| 118 |
+
"admin_create_api_key",
|
| 119 |
+
"admin_revoke_api_key",
|
| 120 |
}
|
| 121 |
|
| 122 |
+
# Read-only data endpoints external partners (e.g. Africa PID Alliance /
|
| 123 |
+
# DOCiD) may reach with a server-to-server API key instead of a browser
|
| 124 |
+
# session — see uraas.dashboard.app._check_api_key and
|
| 125 |
+
# scripts/manage_api_keys.py. Deliberately a small, explicit allowlist: a
|
| 126 |
+
# valid key never grants anything outside it, including ADMIN_ENDPOINTS —
|
| 127 |
+
# partner keys are read-only by construction, not just by convention.
|
| 128 |
+
PARTNER_ENDPOINTS = {
|
| 129 |
+
"get_stats",
|
| 130 |
+
"papers_tree",
|
| 131 |
+
"get_paper",
|
| 132 |
+
"download_paper",
|
| 133 |
+
"export_single_bibtex",
|
| 134 |
+
"special_collections",
|
| 135 |
+
"special_collections_overview",
|
| 136 |
+
"get_university_registry",
|
| 137 |
+
"institution_info",
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
# Simple in-memory sliding-window limiter for API-key traffic, separate from
|
| 141 |
+
# the IP-keyed `limiter` above (a partner integration calls from a small,
|
| 142 |
+
# fixed set of IPs the per-IP limiter wasn't designed to distinguish from
|
| 143 |
+
# abuse). Process-local only — fine for a handful of trusted partner keys on
|
| 144 |
+
# a single dashboard instance; would need a shared store (e.g. Redis) behind
|
| 145 |
+
# multiple worker processes.
|
| 146 |
+
_API_KEY_RATE_LIMIT = 120 # requests per rolling 60s window, per key
|
| 147 |
+
_api_key_hits: dict[str, list] = {}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _check_api_key():
|
| 151 |
+
"""Validate the X-API-Key header against uraas.database.ApiKey.
|
| 152 |
+
|
| 153 |
+
Returns (True, partner_name) if valid, unrevoked, in-quota, and the
|
| 154 |
+
caller updates ApiKey.last_used_at as a side effect. Returns
|
| 155 |
+
(False, (response, status)) otherwise, ready to return directly from
|
| 156 |
+
the before_request hook.
|
| 157 |
+
"""
|
| 158 |
+
raw_key = request.headers.get("X-API-Key")
|
| 159 |
+
if not raw_key:
|
| 160 |
+
return False, None
|
| 161 |
+
|
| 162 |
+
key_hash = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
| 163 |
+
session_db = SessionLocal()
|
| 164 |
+
try:
|
| 165 |
+
row = session_db.query(ApiKey).filter(ApiKey.key_hash == key_hash).first()
|
| 166 |
+
if not row or row.revoked:
|
| 167 |
+
return False, (jsonify({"status": "error", "message": "Invalid or revoked API key"}), 401)
|
| 168 |
+
|
| 169 |
+
now = time.time()
|
| 170 |
+
hits = [t for t in _api_key_hits.get(key_hash, []) if now - t < 60]
|
| 171 |
+
if len(hits) >= _API_KEY_RATE_LIMIT:
|
| 172 |
+
return False, (jsonify({"status": "error", "message": "Rate limit exceeded"}), 429)
|
| 173 |
+
hits.append(now)
|
| 174 |
+
_api_key_hits[key_hash] = hits
|
| 175 |
+
|
| 176 |
+
row.last_used_at = datetime.utcnow()
|
| 177 |
+
session_db.commit()
|
| 178 |
+
return True, row.name
|
| 179 |
+
finally:
|
| 180 |
+
session_db.close()
|
| 181 |
+
|
| 182 |
|
| 183 |
@app.before_request
|
| 184 |
def _enforce_authentication():
|
|
|
|
| 186 |
# Unknown endpoint (404s) and explicit public routes pass through.
|
| 187 |
if endpoint is None or endpoint in PUBLIC_ENDPOINTS:
|
| 188 |
return None
|
| 189 |
+
|
| 190 |
+
if request.headers.get("X-API-Key"):
|
| 191 |
+
# Check the allowlist before touching the DB/rate-limit counter at
|
| 192 |
+
# all — an API key is never a path to an endpoint outside
|
| 193 |
+
# PARTNER_ENDPOINTS, so there's nothing to gain by validating first.
|
| 194 |
+
if endpoint not in PARTNER_ENDPOINTS:
|
| 195 |
+
return jsonify({"status": "error", "message": "This endpoint is not available via API key"}), 403
|
| 196 |
+
ok, result = _check_api_key()
|
| 197 |
+
if not ok:
|
| 198 |
+
return result
|
| 199 |
+
return None # valid partner key, allowlisted endpoint
|
| 200 |
+
|
| 201 |
role = current_role()
|
| 202 |
if not role:
|
| 203 |
if request.path.startswith("/api/"):
|
|
|
|
| 1907 |
)
|
| 1908 |
|
| 1909 |
|
| 1910 |
+
@app.route("/api/admin/api-keys", methods=["GET"])
|
| 1911 |
+
def admin_list_api_keys():
|
| 1912 |
+
"""List partner API keys (admin only). Never returns full key values —
|
| 1913 |
+
only the prefix stored at creation, same as scripts/manage_api_keys.py."""
|
| 1914 |
+
session_db = SessionLocal()
|
| 1915 |
+
try:
|
| 1916 |
+
rows = session_db.query(ApiKey).order_by(ApiKey.created_at.desc()).all()
|
| 1917 |
+
return api_ok({
|
| 1918 |
+
"keys": [
|
| 1919 |
+
{
|
| 1920 |
+
"id": r.id,
|
| 1921 |
+
"name": r.name,
|
| 1922 |
+
"prefix": r.key_prefix,
|
| 1923 |
+
"scope": r.scope,
|
| 1924 |
+
"revoked": r.revoked,
|
| 1925 |
+
"created_at": r.created_at.isoformat() if r.created_at else None,
|
| 1926 |
+
"last_used_at": r.last_used_at.isoformat() if r.last_used_at else None,
|
| 1927 |
+
}
|
| 1928 |
+
for r in rows
|
| 1929 |
+
]
|
| 1930 |
+
})
|
| 1931 |
+
finally:
|
| 1932 |
+
session_db.close()
|
| 1933 |
+
|
| 1934 |
+
|
| 1935 |
+
@app.route("/api/admin/api-keys", methods=["POST"])
|
| 1936 |
+
def admin_create_api_key():
|
| 1937 |
+
"""Issue a new partner API key (admin only). The plaintext value is
|
| 1938 |
+
returned exactly once, in this response — only its hash is persisted, so
|
| 1939 |
+
it can never be retrieved again after this call."""
|
| 1940 |
+
data = request.get_json(silent=True) or {}
|
| 1941 |
+
name = (data.get("name") or "").strip()
|
| 1942 |
+
if not name:
|
| 1943 |
+
return api_error("name is required", status=400)
|
| 1944 |
+
|
| 1945 |
+
raw = "uraas_live_" + secrets.token_urlsafe(32)
|
| 1946 |
+
session_db = SessionLocal()
|
| 1947 |
+
try:
|
| 1948 |
+
row = ApiKey(
|
| 1949 |
+
name=name,
|
| 1950 |
+
key_hash=hashlib.sha256(raw.encode("utf-8")).hexdigest(),
|
| 1951 |
+
key_prefix=raw[:20],
|
| 1952 |
+
scope="read",
|
| 1953 |
+
created_by=session.get("user"),
|
| 1954 |
+
)
|
| 1955 |
+
session_db.add(row)
|
| 1956 |
+
session_db.commit()
|
| 1957 |
+
return api_ok({
|
| 1958 |
+
"id": row.id,
|
| 1959 |
+
"name": row.name,
|
| 1960 |
+
"prefix": row.key_prefix,
|
| 1961 |
+
"key": raw,
|
| 1962 |
+
}, narrative="Save this key now — it will not be shown again.")
|
| 1963 |
+
finally:
|
| 1964 |
+
session_db.close()
|
| 1965 |
+
|
| 1966 |
+
|
| 1967 |
+
@app.route("/api/admin/api-keys/<int:key_id>/revoke", methods=["POST"])
|
| 1968 |
+
def admin_revoke_api_key(key_id):
|
| 1969 |
+
"""Revoke a partner API key (admin only)."""
|
| 1970 |
+
session_db = SessionLocal()
|
| 1971 |
+
try:
|
| 1972 |
+
row = session_db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
| 1973 |
+
if not row:
|
| 1974 |
+
return api_error("Key not found", status=404)
|
| 1975 |
+
row.revoked = True
|
| 1976 |
+
row.revoked_at = datetime.utcnow()
|
| 1977 |
+
session_db.commit()
|
| 1978 |
+
return api_ok({"id": row.id, "revoked": True})
|
| 1979 |
+
finally:
|
| 1980 |
+
session_db.close()
|
| 1981 |
+
|
| 1982 |
+
|
| 1983 |
@app.route("/api/author/<int:author_id>/metrics")
|
| 1984 |
def get_author_metrics(author_id):
|
| 1985 |
"""Get bibliometric indicators for an author (h-index, citations, etc.)."""
|
uraas/database.py
CHANGED
|
@@ -368,6 +368,30 @@ class DepositBatch(Base):
|
|
| 368 |
deposit_log = Column(Text, default="[]")
|
| 369 |
|
| 370 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
# ── Indexes for query performance ─────────────────────────────────────────────
|
| 372 |
# ix_items_docid is auto-created by index=True on Item.docid — no duplicate needed
|
| 373 |
Index("ix_items_language", Item.language_code)
|
|
|
|
| 368 |
deposit_log = Column(Text, default="[]")
|
| 369 |
|
| 370 |
|
| 371 |
+
class ApiKey(Base):
|
| 372 |
+
"""Server-to-server credential for external partners (e.g. Africa PID
|
| 373 |
+
Alliance / DOCiD) reading URAAS data programmatically — distinct from the
|
| 374 |
+
dashboard's session-cookie login, which only suits a human in a browser.
|
| 375 |
+
|
| 376 |
+
The plaintext key is shown exactly once at creation time (see
|
| 377 |
+
scripts/manage_api_keys.py) and never stored — only its SHA-256 hash is
|
| 378 |
+
persisted, so a stolen database dump can't be used to authenticate.
|
| 379 |
+
"""
|
| 380 |
+
|
| 381 |
+
__tablename__ = "api_keys"
|
| 382 |
+
|
| 383 |
+
id = Column(Integer, primary_key=True)
|
| 384 |
+
name = Column(String(255), nullable=False) # partner label, e.g. "Africa PID Alliance / DOCiD"
|
| 385 |
+
key_hash = Column(String(64), unique=True, index=True, nullable=False) # sha256 hex digest
|
| 386 |
+
key_prefix = Column(String(12), nullable=False) # first chars only, for display/audit
|
| 387 |
+
scope = Column(String(20), default="read", nullable=False) # "read" is the only scope for now — never admin
|
| 388 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 389 |
+
created_by = Column(String(100)) # admin session username that issued it
|
| 390 |
+
last_used_at = Column(DateTime)
|
| 391 |
+
revoked = Column(Boolean, default=False, nullable=False)
|
| 392 |
+
revoked_at = Column(DateTime)
|
| 393 |
+
|
| 394 |
+
|
| 395 |
# ── Indexes for query performance ─────────────────────────────────────────────
|
| 396 |
# ix_items_docid is auto-created by index=True on Item.docid — no duplicate needed
|
| 397 |
Index("ix_items_language", Item.language_code)
|