Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
430f8e4
feat(smartdiff): semantic subtitle diff core (SRT parser + classifier)
gaurav-dev02 Jun 24, 2026
4e8144b
feat(smartdiff): add WebVTT support and a format dispatcher
gaurav-dev02 Jun 24, 2026
cda6de8
feat(smartdiff): CCExtractor-grounded normalization + cosmetic-diff k…
gaurav-dev02 Jun 26, 2026
8f59b35
feat(smartdiff): timing drift, split/merged cues, real golden fixtures
gaurav-dev02 Jun 26, 2026
9c96da9
feat(smartdiff): detect encoding-only differences (non-ASCII/accents)
gaurav-dev02 Jun 29, 2026
27ab78b
test(smartdiff): real DVB Spanish golden fixture + strict & robustnes…
gaurav-dev02 Jun 29, 2026
57b0a25
feat(smartdiff): expose smart diff via endpoint + a "Smart" option in…
gaurav-dev02 Jun 29, 2026
1b1e75e
feat(smartdiff): per-cue change detail (which cues, expected/actual, …
gaurav-dev02 Jun 29, 2026
8c1f731
style(smartdiff): stack Fail / Smart vertically with a divider
gaurav-dev02 Jun 30, 2026
0c7e5e8
fix(smartdiff): address code-review findings
gaurav-dev02 Jun 30, 2026
ba8d4f0
Merge branch 'master' into feat/smart-diff
gaurav02081 Jun 30, 2026
2d08882
Replace SysV init with native systemd unit to fix gunicorn duplicate-…
x15sr71 Jun 28, 2026
267569d
Add trailing newline to bootstrap_gunicorn.py
x15sr71 Jun 28, 2026
372acec
refactor(install): template systemd service base directory
x15sr71 Jul 18, 2026
452eb28
chore(deps): bump gitpython from 3.1.50 to 3.1.55
dependabot[bot] Jul 23, 2026
872477a
chore(deps): bump coverage from 7.14.3 to 7.15.2
dependabot[bot] Jul 16, 2026
abb6839
chore(deps): bump google-cloud-storage from 3.12.0 to 3.13.0
dependabot[bot] Jul 14, 2026
b1b44cd
chore(deps): bump mypy from 2.1.0 to 2.3.0
dependabot[bot] Jul 13, 2026
6aeaec4
chore(deps): bump cffi from 2.0.0 to 2.1.0
dependabot[bot] Jul 25, 2026
2c54e3d
chore(deps): bump tzlocal from 5.4.3 to 5.4.4
dependabot[bot] Jul 25, 2026
bcddfce
chore(deps): bump click from 8.4.1 to 8.4.2
dependabot[bot] Jul 25, 2026
8d95d3d
chore(deps): bump google-api-python-client from 2.197.0 to 2.198.0
dependabot[bot] Jul 25, 2026
149eed8
PR 1: Foundation and scaffolding
pulk17 Jul 20, 2026
90ddb67
PR 2: Auth and Token Management Endpoints
pulk17 Jul 20, 2026
fec5198
chore(deps): bump marshmallow from 3.25.1 to 3.26.2
dependabot[bot] Jul 26, 2026
8560160
chore(deps): bump gitpython from 3.1.55 to 3.1.57
dependabot[bot] Jul 27, 2026
6530e9d
chore(deps): bump pytz from 2026.2 to 2026.3.post1
dependabot[bot] Jul 27, 2026
1864a20
chore(deps): bump marshmallow from 3.26.2 to 4.3.0
dependabot[bot] Jul 27, 2026
31564fc
PR 3: System Routes and Run Execution Endpoints
pulk17 Jul 20, 2026
08c0783
PR 4: Samples Endpoints
pulk17 Jul 20, 2026
6289ac8
PR 5: Results Data and Baseline Approvals Endpoints
pulk17 Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pycodestylerc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[pycodestyle]
count = True
max-line-length = 120
exclude=test_diff.py,migrations,venv*,parse.py,config.py
exclude=test_diff.py,migrations,venv*,.venv*,parse.py,config.py
ignore = E701
8 changes: 4 additions & 4 deletions bootstrap_gunicorn.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
current_dir = path.dirname(path.abspath(__file__))
TIMEOUT = 120 # In seconds

# Arguments to start gunicorn
args = [
"gunicorn", "-w", "4", "--daemon", "--pid", "gunicorn.pid", "-b", "unix:sampleplatform.sock", "-m", "007",
"-g", "www-data", "-u", "www-data", f"--chdir={current_dir}", "--log-level", "debug", "--timeout", f"{TIMEOUT}",
"gunicorn", "-w", "4", "-b", "unix:sampleplatform.sock", "-m", "007",
"-g", "www-data", "-u", "www-data", f"--chdir={current_dir}", "--log-level", "debug",
"--timeout", f"{TIMEOUT}",
"--access-logfile", f"{current_dir}/logs/access.log", "--capture-output",
"--log-file", f"{current_dir}/logs/error.log", "run:app"
]

subprocess.Popen(args)
subprocess.run(args)
10 changes: 6 additions & 4 deletions install/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,12 @@ chown -R www-data:www-data "${root_dir}" "${sample_repository}"
echo "* Creating startup script"

{
cp "${dir}/platform" /etc/init.d/platform
sed -i "s#BASE_DIR#${root_dir}#g" /etc/init.d/platform
chmod 755 /etc/init.d/platform
update-rc.d platform defaults
rm -f /etc/init.d/platform
update-rc.d platform remove || true
cp "${dir}/platform.service" /etc/systemd/system/platform.service
sed -i "s|#BASE_DIR#|${root_dir}|g" /etc/systemd/system/platform.service
systemctl daemon-reload
systemctl enable platform.service
} >> "$install_log" 2>&1
echo "* Creating RClone config file"

Expand Down
2 changes: 1 addition & 1 deletion install/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ sudo python3 bootstrap_gunicorn.py
1. Firstly check the Platform Installation log file in the install folder. Check for any errors, which may have been caused during platform installation on your system, and then try to resolve them accordingly.
2. Next check for nginx status by `service nginx status` command, if it is not active, check nginx error log file, possibly in `/var/log/nginx/error.log` file.
3. Next check for platform status by `service platform status` command, if it is not `active(running)` then check for platform logs in the `logs` directory of your project.
4. In case of any gunicorn error try manually running `/etc/init.d/platform start` command and recheck the platform status.
4. In case of any gunicorn error try manually running `sudo systemctl start platform.service` command and recheck the platform status.

### Setting Up The Bucket

Expand Down
27 changes: 0 additions & 27 deletions install/platform

This file was deleted.

19 changes: 19 additions & 0 deletions install/platform.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[Unit]
Description=CCExtractor Sample Platform (gunicorn)
After=network.target mysql.service
Wants=mysql.service

[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=#BASE_DIR#
ExecStart=/usr/bin/gunicorn -w 4 -b unix:#BASE_DIR#/sampleplatform.sock -m 007 --timeout 120 --log-level debug --access-logfile #BASE_DIR#/logs/access.log --capture-output --log-file #BASE_DIR#/logs/error.log run:app
ExecReload=/bin/kill -s HUP $MAINPID
Restart=on-failure
RestartSec=5
KillMode=control-group
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target
44 changes: 44 additions & 0 deletions migrations/versions/d4f8e2a1b3c7_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Add api_token table for scoped API token auth.

Revision ID: d4f8e2a1b3c7
Revises: c8f3a2b1d4e5
Create Date: 2026-06-11 03:00:00.000000

"""
import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = 'd4f8e2a1b3c7'
down_revision = 'c8f3a2b1d4e5'
branch_labels = None
depends_on = None


def upgrade():
"""Apply the migration."""
op.add_column('user', sa.Column('github_login', sa.String(length=255), nullable=True))
op.create_table(
'api_token',
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token_name', sa.String(length=50), nullable=False),
sa.Column('token_hash', sa.String(length=255), nullable=False),
sa.Column('token_prefix', sa.String(length=16), nullable=False),
sa.Column('scopes_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], onupdate='CASCADE', ondelete='CASCADE'),
sa.UniqueConstraint('user_id', 'token_name', name='uq_user_token_name'),
mysql_engine='InnoDB'
)
op.create_index('ix_api_token_token_prefix', 'api_token', ['token_prefix'])


def downgrade():
"""Revert the migration."""
op.drop_index('ix_api_token_token_prefix', table_name='api_token')
op.drop_table('api_token')
op.drop_column('user', 'github_login')
42 changes: 42 additions & 0 deletions mod_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""
mod_api: JSON REST API blueprint for the CCExtractor CI platform.

Registered at /api/v1. All endpoints return structured JSON, use scoped
Bearer token auth, and enforce per-client rate limiting.
"""

from flask import Blueprint

mod_api = Blueprint('api', __name__)

# Middleware imports
from mod_api.middleware import auth # noqa: E402
from mod_api.middleware import error_handler # noqa: E402
from mod_api.middleware import rate_limit # noqa: E402
from mod_api.middleware import security # noqa: E402

# Explicitly register before_request hooks in the exact order they should run
mod_api.before_request(auth.authenticate_request)
mod_api.before_request(rate_limit.check_rate_limit)
mod_api.before_request(auth.enforce_auth_error)

# Explicitly register after_request hooks.
# NOTE: Flask executes after_request hooks in REVERSE registration order.
# Registration: security → rate_limit → (convert is app-level, see below)
# Execution: rate_limit → security
# This means rate-limit headers are added first, then security headers layer
# on top — both on the same response object.
mod_api.after_request(security.add_security_headers)
mod_api.after_request(rate_limit.add_rate_limit_headers)

# Registered as after_app_request so it fires for ALL requests (including
# routing-level 404s/405s that never enter the blueprint).
mod_api.after_app_request(error_handler.convert_api_errors_to_json)

# Route modules register themselves against the blueprint; the rest of
# the stack adds one module per PR.
from mod_api.routes import auth as auth_routes # noqa: E402, F401
from mod_api.routes import results as results_routes # noqa: E402, F401
from mod_api.routes import runs as runs_routes # noqa: E402, F401
from mod_api.routes import samples as samples_routes # noqa: E402, F401
from mod_api.routes import system as system_routes # noqa: E402, F401
1 change: 1 addition & 0 deletions mod_api/middleware/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""mod_api.middleware: auth, rate limiting, validation, and error handling."""
146 changes: 146 additions & 0 deletions mod_api/middleware/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""
Bearer token authentication and scope/role enforcement for API routes.

Runs as a before_request hook on the api blueprint. Public endpoints
(token creation, health check) are exempted. On success, the authenticated
user and token are stored in flask.g for downstream handlers.

HTTP semantics:
401 = token missing, expired, revoked, or invalid
403 = valid token but insufficient scope or role
"""

import functools
from typing import Any, List

from flask import g, request

from mod_api.middleware.error_handler import make_error_response
from mod_api.models.api_token import TOKEN_PREFIX, ApiToken

_AUTH_FAILED_MSG = 'Bearer token is missing, expired, or invalid.'

# These endpoints bypass auth entirely.
_PUBLIC_ENDPOINTS = frozenset([
'api.create_token', # POST /auth/tokens (uses email/password body)
'api.system_health', # GET /system/health (uptime monitoring)
])


def _unauthorized():
"""Shorthand for a 401 response with the standard auth failure message."""
return make_error_response(
'unauthorized', _AUTH_FAILED_MSG, http_status=401)


def authenticate_request():
"""Validate Bearer token and attach user context to the request.

If auth fails, sets g.auth_error instead of returning immediately,
so that subsequent hooks (like rate limiting) still run.
"""
if request.endpoint in _PUBLIC_ENDPOINTS:
g.api_user = None
g.api_token = None
return

auth_header = request.headers.get('Authorization', '')
if not auth_header:
g.auth_error = _unauthorized()
return

parts = auth_header.split(' ', 1)
# Auth scheme names are case-insensitive (RFC 7235 section 2.1).
if len(parts) != 2 or parts[0].lower() != 'bearer':
g.auth_error = _unauthorized()
return

token_value = parts[1].strip()
if not token_value or not token_value.startswith(TOKEN_PREFIX):
g.auth_error = _unauthorized()
return

# Look up by prefix, then verify the full hash against each candidate.
prefix = ApiToken.extract_prefix(token_value)
candidates = ApiToken.query.filter_by(token_prefix=prefix).all()

if not candidates:
g.auth_error = _unauthorized()
return

matched_token = None
for candidate in candidates:
if ApiToken.verify_token(token_value, candidate.token_hash):
matched_token = candidate
break

if matched_token is None:
g.auth_error = _unauthorized()
return

if not matched_token.is_valid:
g.auth_error = _unauthorized()
return

g.api_token = matched_token
g.api_user = matched_token.user


def enforce_auth_error():
"""Return any stored auth errors after rate limiting."""
if hasattr(g, 'auth_error') and g.auth_error is not None:
return g.auth_error


def require_scope(*scopes: str):
"""Reject the request if the token lacks any of the ``scopes``."""
def decorator(f):
@functools.wraps(f)
def decorated_function(*args, **kwargs):
token = getattr(g, 'api_token', None)
if token is None:
return _unauthorized()

missing_scopes = [s for s in scopes if not token.has_scope(s)]
if missing_scopes:
return make_error_response(
'forbidden',
'Token lacks the required scopes for this operation.',
details={
'required_scopes': list(scopes),
'missing_scopes': missing_scopes,
'token_scopes': token.scopes,
},
http_status=403,
)
return f(*args, **kwargs)
return decorated_function
return decorator


def require_roles(roles: List[Any]):
"""Reject the request if the user's role is not in ``roles``.

Takes Role members (not strings), matching check_access_rights in
mod_auth. Typed loosely because DeclEnum members are EnumSymbol
instances at runtime but plain tuples to the type checker.
"""
def decorator(f):
@functools.wraps(f)
def decorated_function(*args, **kwargs):
user = getattr(g, 'api_user', None)
if user is None:
return _unauthorized()
if user.role not in roles:
return make_error_response(
'forbidden',
'Your role does not have permission for this operation.',
details={
'required_roles': [role.value for role in roles],
'user_role': user.role.value,
},
http_status=403,
)
return f(*args, **kwargs)
return decorated_function
return decorator
Loading