Page Menu
Home
Software Heritage
Search
Configure Global Search
Log In
Files
F9123982
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
51 KB
Subscribers
None
View Options
diff --git a/cypress/e2e/add-forge-now-requests-moderation.cy.js b/cypress/e2e/add-forge-now-requests-moderation.cy.js
index bcc2fa17..8ead52d6 100644
--- a/cypress/e2e/add-forge-now-requests-moderation.cy.js
+++ b/cypress/e2e/add-forge-now-requests-moderation.cy.js
@@ -1,125 +1,125 @@
/**
* Copyright (C) 2022 The Software Heritage developers
* See the AUTHORS file at the top-level directory of this distribution
* License: GNU Affero General Public License version 3, or any later version
* See top-level LICENSE file for more information
*/
-const defaultRedirect = '/admin/login/';
+const defaultRedirect = '/login/';
let addForgeModerationUrl;
let listAddForgeRequestsUrl;
function logout() {
cy.contains('a', 'logout')
.click();
}
describe('Test "Add Forge Now" moderation Login/logout', function() {
before(function() {
addForgeModerationUrl = this.Urls.add_forge_now_requests_moderation();
});
it('should redirect to default page', function() {
cy.visit(addForgeModerationUrl)
.get('input[name="username"]')
.type('admin')
.get('input[name="password"]')
.type('admin')
.get('.container form')
.submit();
cy.location('pathname')
.should('be.equal', addForgeModerationUrl);
});
it('should redirect to correct page after login', function() {
cy.visit(addForgeModerationUrl)
.location('pathname')
.should('be.equal', defaultRedirect);
cy.adminLogin();
cy.visit(addForgeModerationUrl)
.location('pathname')
.should('be.equal', addForgeModerationUrl);
logout();
});
it('should not display moderation link in sidebar when anonymous', function() {
cy.visit(addForgeModerationUrl);
cy.get(`.sidebar a[href="${addForgeModerationUrl}"]`)
.should('not.exist');
});
it('should not display moderation link when connected as unprivileged user', function() {
cy.userLogin();
cy.visit(addForgeModerationUrl);
cy.get(`.sidebar a[href="${addForgeModerationUrl}"]`)
.should('not.exist');
});
it('should display moderation link in sidebar when connected as privileged user', function() {
cy.addForgeModeratorLogin();
cy.visit(addForgeModerationUrl);
cy.get(`.sidebar a[href="${addForgeModerationUrl}"]`)
.should('exist');
});
it('should display moderation link in sidebar when connected as staff member', function() {
cy.adminLogin();
cy.visit(addForgeModerationUrl);
cy.get(`.sidebar a[href="${addForgeModerationUrl}"]`)
.should('exist');
});
});
describe('Test "Add Forge Now" moderation listing', function() {
before(function() {
addForgeModerationUrl = this.Urls.add_forge_now_requests_moderation();
listAddForgeRequestsUrl = this.Urls.add_forge_request_list_datatables();
});
it('should list add-forge-now requests', function() {
cy.intercept(`${listAddForgeRequestsUrl}**`, {fixture: 'add-forge-now-requests'}).as('listRequests');
let expectedRequests;
cy.readFile('cypress/fixtures/add-forge-now-requests.json').then((result) => {
expectedRequests = result['data'];
});
cy.addForgeModeratorLogin();
cy.visit(addForgeModerationUrl);
cy.get('.swh-add-forge-now-moderation-item')
.should('have.class', 'active');
cy.wait('@listRequests').then((xhr) => {
cy.log('response:', xhr.response);
cy.log(xhr.response.body);
const requests = xhr.response.body.data;
cy.log('Requests: ', requests);
expect(requests.length).to.equal(expectedRequests.length);
cy.get('#swh-add-forge-now-moderation-list').find('tbody > tr').as('rows');
// only 2 entries
cy.get('@rows').each((row, idx, collection) => {
const request = requests[idx];
const expectedRequest = expectedRequests[idx];
assert.isNotNull(request);
assert.isNotNull(expectedRequest);
expect(request.id).to.be.equal(expectedRequest['id']);
expect(request.status).to.be.equal(expectedRequest['status']);
expect(request.submission_date).to.be.equal(expectedRequest['submission_date']);
expect(request.forge_type).to.be.equal(expectedRequest['forge_type']);
expect(request.forge_url).to.be.equal(expectedRequest['forge_url']);
});
});
});
});
diff --git a/cypress/e2e/admin.cy.js b/cypress/e2e/admin.cy.js
index 5428a047..ca655f7b 100644
--- a/cypress/e2e/admin.cy.js
+++ b/cypress/e2e/admin.cy.js
@@ -1,295 +1,295 @@
/**
* Copyright (C) 2019-2022 The Software Heritage developers
* See the AUTHORS file at the top-level directory of this distribution
* License: GNU Affero General Public License version 3, or any later version
* See top-level LICENSE file for more information
*/
const $ = Cypress.$;
-const defaultRedirect = '/admin/origin/save/requests/';
+const defaultRedirect = '/';
let url;
function logout() {
cy.contains('a', 'logout')
.click();
}
describe('Test Admin Login/logout', function() {
before(function() {
- url = this.Urls.admin();
+ url = this.Urls.login();
});
it('should redirect to default page', function() {
cy.visit(url)
.get('input[name="username"]')
.type('admin')
.get('input[name="password"]')
.type('admin')
.get('.container form')
.submit();
cy.location('pathname')
.should('be.equal', defaultRedirect);
logout();
});
it('should display admin-origin-save and deposit in sidebar', function() {
cy.adminLogin();
cy.visit(url);
cy.get(`.sidebar a[href="${this.Urls.admin_origin_save_requests()}"]`)
.should('be.visible');
cy.get(`.sidebar a[href="${this.Urls.admin_deposit()}"]`)
.should('be.visible');
logout();
});
it('should display username on top-right', function() {
cy.adminLogin();
cy.visit(url);
cy.get('.swh-position-right')
.should('contain', 'admin');
logout();
});
it('should get info about a user logged in from javascript', function() {
cy.window().then(win => {
expect(win.swh.webapp.isUserLoggedIn()).to.be.false;
});
cy.adminLogin();
cy.visit(url);
cy.window().then(win => {
expect(win.swh.webapp.isUserLoggedIn()).to.be.true;
});
logout();
cy.visit(url);
cy.window().then(win => {
expect(win.swh.webapp.isUserLoggedIn()).to.be.false;
});
});
it('should prevent unauthorized access after logout', function() {
cy.visit(this.Urls.admin_origin_save_requests())
.location('pathname')
- .should('be.equal', '/admin/login/');
+ .should('be.equal', '/login/');
cy.visit(this.Urls.admin_deposit())
.location('pathname')
- .should('be.equal', '/admin/login/');
+ .should('be.equal', '/login/');
});
it('should redirect to correct page after login', function() {
// mock calls to deposit list api to avoid possible errors
// while running the test
cy.intercept(`${this.Urls.admin_deposit_list()}**`, {
body: {
data: [],
recordsTotal: 0,
recordsFiltered: 0,
draw: 1
}
});
cy.visit(this.Urls.admin_deposit())
.location('search')
.should('contain', `next=${this.Urls.admin_deposit()}`);
cy.adminLogin();
cy.visit(this.Urls.admin_deposit());
cy.location('pathname')
.should('be.equal', this.Urls.admin_deposit());
logout();
});
});
const existingRowToSelect = 'https://bitbucket.org/';
const originUrlListTestData = [
{
listType: 'authorized',
originToAdd: 'git://git.archlinux.org/',
originToRemove: 'https://github.com/'
},
{
listType: 'unauthorized',
originToAdd: 'https://random.org',
originToRemove: 'https://gitlab.com'
}
];
const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1);
describe('Test Admin Origin Save Urls Filtering', function() {
beforeEach(function() {
cy.adminLogin();
cy.visit(this.Urls.admin_origin_save_requests());
cy.contains('a', 'Origin urls filtering')
.click()
.wait(500);
});
it(`should select or unselect a table row by clicking on it`, function() {
cy.contains(`#swh-authorized-origin-urls tr`, existingRowToSelect)
.click()
.should('have.class', 'selected')
.click()
.should('not.have.class', 'selected');
});
originUrlListTestData.forEach(testData => {
it(`should add a new origin url prefix in the ${testData.listType} list`, function() {
const tabName = capitalize(testData.listType) + ' urls';
cy.contains('a', tabName)
.click()
.wait(500);
cy.get(`#swh-${testData.listType}-origin-urls tr`).each(elt => {
if ($(elt).text() === testData.originToAdd) {
cy.get(elt).click();
cy.get(`#swh-remove-${testData.listType}-origin-url`).click();
}
});
cy.get(`#swh-${testData.listType}-url-prefix`)
.type(testData.originToAdd);
cy.get(`#swh-add-${testData.listType}-origin-url`)
.click();
cy.contains(`#swh-${testData.listType}-origin-urls tr`, testData.originToAdd)
.should('be.visible');
cy.contains('.alert-success', `The origin url prefix has been successfully added in the ${testData.listType} list.`)
.should('be.visible');
cy.get(`#swh-add-${testData.listType}-origin-url`)
.click();
cy.contains('.alert-warning', `The provided origin url prefix is already registered in the ${testData.listType} list.`)
.should('be.visible');
});
it(`should remove an origin url prefix from the ${testData.listType} list`, function() {
const tabName = capitalize(testData.listType) + ' urls';
cy.contains('a', tabName)
.click();
let originUrlMissing = true;
cy.get(`#swh-${testData.listType}-origin-urls tr`).each(elt => {
if ($(elt).text() === testData.originToRemove) {
originUrlMissing = false;
}
});
if (originUrlMissing) {
cy.get(`#swh-${testData.listType}-url-prefix`)
.type(testData.originToRemove);
cy.get(`#swh-add-${testData.listType}-origin-url`)
.click();
cy.get('.alert-dismissible button').click();
}
cy.contains(`#swh-${testData.listType}-origin-urls tr`, testData.originToRemove)
.click();
cy.get(`#swh-remove-${testData.listType}-origin-url`).click();
cy.contains(`#swh-${testData.listType}-origin-urls tr`, testData.originToRemove)
.should('not.exist');
});
});
});
describe('Test Admin Origin Save', function() {
it(`should reject a save code now request with note`, function() {
const originUrl = `https://example.org/${Date.now()}`;
const rejectionNote = 'The provided URL does not target a git repository.';
// anonymous user creates a request put in pending state
cy.visit(this.Urls.origin_save());
cy.get('#swh-input-origin-url')
.type(originUrl);
cy.get('#swh-input-origin-save-submit')
.click();
// admin user logs in and visits save code now admin page
cy.adminLogin();
cy.visit(this.Urls.admin_origin_save_requests());
// admin rejects the save request and adds a rejection note
cy.contains('#swh-origin-save-pending-requests', originUrl)
.click();
cy.get('#swh-reject-save-origin-request')
.click();
cy.get('#swh-rejection-text')
.then(textarea => {
textarea.val(rejectionNote);
});
cy.get('#swh-rejection-submit')
.click();
cy.get('#swh-web-modal-confirm-ok-btn')
.click();
// checks rejection note has been saved to swh-web database
cy.request(this.Urls.api_1_save_origin('git', originUrl))
.then(response => {
expect(response.body[0]['note']).to.equal(rejectionNote);
});
// check rejection note is displayed by clicking on the info icon
// in requests table from public save code now page
cy.visit(this.Urls.origin_save());
cy.get('#swh-origin-save-requests-list-tab')
.click();
cy.contains('#swh-origin-save-requests tr', originUrl);
cy.get('.swh-save-request-info')
.eq(0)
.click();
cy.get('.popover-body')
.should('have.text', rejectionNote);
// remove rejected request from swh-web database to avoid side effects
// in tests located in origin-save.spec.js
cy.visit(this.Urls.admin_origin_save_requests());
cy.get('#swh-save-requests-rejected-tab')
.click();
cy.contains('#swh-origin-save-rejected-requests', originUrl)
.click();
cy.get('#swh-remove-rejected-save-origin-request')
.click();
cy.get('#swh-web-modal-confirm-ok-btn')
.click();
});
});
diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js
index e6d417f8..b0f015aa 100644
--- a/cypress/support/e2e.js
+++ b/cypress/support/e2e.js
@@ -1,116 +1,116 @@
/**
* Copyright (C) 2019-2022 The Software Heritage developers
* See the AUTHORS file at the top-level directory of this distribution
* License: GNU Affero General Public License version 3, or any later version
* See top-level LICENSE file for more information
*/
import 'cypress-hmr-restarter';
import '@cypress/code-coverage/support';
Cypress.Screenshot.defaults({
screenshotOnRunFailure: false
});
Cypress.Commands.add('xhrShouldBeCalled', (alias, timesCalled) => {
const testRoutes = cy.state('routes');
const aliasRoute = Cypress._.find(testRoutes, {alias});
expect(Object.keys(aliasRoute.requests || {})).to.have.length(timesCalled);
});
function loginUser(username, password) {
- const url = '/admin/login/';
+ const url = '/login/';
return cy.request({
url: url,
method: 'GET'
}).then(() => {
cy.getCookie('sessionid').should('not.exist');
cy.getCookie('csrftoken').its('value').then((token) => {
cy.request({
url: url,
method: 'POST',
form: true,
followRedirect: false,
body: {
username: username,
password: password,
csrfmiddlewaretoken: token
}
}).then(() => {
cy.getCookie('sessionid').should('exist');
return cy.getCookie('csrftoken').its('value');
});
});
});
}
Cypress.Commands.add('adminLogin', () => {
return loginUser('admin', 'admin');
});
Cypress.Commands.add('userLogin', () => {
return loginUser('user', 'user');
});
Cypress.Commands.add('user2Login', () => {
return loginUser('user2', 'user2');
});
Cypress.Commands.add('ambassadorLogin', () => {
return loginUser('ambassador', 'ambassador');
});
Cypress.Commands.add('depositLogin', () => {
return loginUser('deposit', 'deposit');
});
Cypress.Commands.add('addForgeModeratorLogin', () => {
return loginUser('add-forge-moderator', 'add-forge-moderator');
});
function mockCostlyRequests() {
cy.intercept('https://status.softwareheritage.org/**', {
body: {
'result': {
'status': [
{
'id': '5f7c4c567f50b304c1e7bd5f',
'name': 'Save Code Now',
'updated': '2020-11-30T13:51:21.151Z',
'status': 'Operational',
'status_code': 100
}
]
}
}}).as('swhPlatformStatus');
cy.intercept('/coverage', {
body: ''
}).as('swhCoverageWidget');
}
Cypress.Commands.add('mailmapAdminLogin', () => {
return loginUser('mailmap-admin', 'mailmap-admin');
});
before(function() {
mockCostlyRequests();
cy.task('getSwhTestsData').then(testsData => {
Object.assign(this, testsData);
});
cy.visit('/').window().then(async win => {
this.Urls = win.Urls;
});
});
beforeEach(function() {
mockCostlyRequests();
});
Cypress.Commands.overwrite('type', (originalFn, subject, text, options = {}) => {
options.delay = options.delay || 0;
options.force = options.force || true;
return originalFn(subject, text, options);
});
diff --git a/swh/web/admin/urls.py b/swh/web/admin/urls.py
index 43f9471f..dfbba133 100644
--- a/swh/web/admin/urls.py
+++ b/swh/web/admin/urls.py
@@ -1,23 +1,22 @@
# Copyright (C) 2018-2022 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
-from django.contrib.auth.views import LoginView
+
from django.shortcuts import redirect
from django.urls import re_path as url
from swh.web.admin.adminurls import AdminUrls
import swh.web.admin.deposit # noqa
def _admin_default_view(request):
return redirect("admin-origin-save-requests")
urlpatterns = [
url(r"^$", _admin_default_view, name="admin"),
- url(r"^login/$", LoginView.as_view(template_name="login.html"), name="login"),
]
urlpatterns += AdminUrls.get_url_patterns()
diff --git a/swh/web/templates/login.html b/swh/web/auth/templates/login.html
similarity index 100%
rename from swh/web/templates/login.html
rename to swh/web/auth/templates/login.html
diff --git a/swh/web/templates/logout.html b/swh/web/auth/templates/logout.html
similarity index 100%
rename from swh/web/templates/logout.html
rename to swh/web/auth/templates/logout.html
diff --git a/swh/web/templates/auth/profile.html b/swh/web/auth/templates/profile.html
similarity index 100%
rename from swh/web/templates/auth/profile.html
rename to swh/web/auth/templates/profile.html
diff --git a/swh/web/auth/urls.py b/swh/web/auth/urls.py
new file mode 100644
index 00000000..f08e033f
--- /dev/null
+++ b/swh/web/auth/urls.py
@@ -0,0 +1,52 @@
+# Copyright (C) 2022 The Software Heritage developers
+# See the AUTHORS file at the top-level directory of this distribution
+# License: GNU Affero General Public License version 3, or any later version
+# See top-level LICENSE file for more information
+
+from django.contrib.auth.views import LoginView, LogoutView
+from django.urls import re_path as url
+
+from swh.auth.django.views import urlpatterns as auth_urlpatterns
+from swh.web.auth.views import (
+ oidc_generate_bearer_token,
+ oidc_generate_bearer_token_complete,
+ oidc_get_bearer_token,
+ oidc_list_bearer_tokens,
+ oidc_profile_view,
+ oidc_revoke_bearer_tokens,
+)
+
+urlpatterns = auth_urlpatterns + [
+ url(
+ r"^oidc/generate-bearer-token/$",
+ oidc_generate_bearer_token,
+ name="oidc-generate-bearer-token",
+ ),
+ url(
+ r"^oidc/generate-bearer-token-complete/$",
+ oidc_generate_bearer_token_complete,
+ name="oidc-generate-bearer-token-complete",
+ ),
+ url(
+ r"^oidc/list-bearer-token/$",
+ oidc_list_bearer_tokens,
+ name="oidc-list-bearer-tokens",
+ ),
+ url(
+ r"^oidc/get-bearer-token/$",
+ oidc_get_bearer_token,
+ name="oidc-get-bearer-token",
+ ),
+ url(
+ r"^oidc/revoke-bearer-tokens/$",
+ oidc_revoke_bearer_tokens,
+ name="oidc-revoke-bearer-tokens",
+ ),
+ url(
+ r"^oidc/profile/$",
+ oidc_profile_view,
+ name="oidc-profile",
+ ),
+ url(r"^login/$", LoginView.as_view(template_name="login.html"), name="login"),
+ url(r"^logout/$", LogoutView.as_view(template_name="logout.html"), name="logout"),
+]
diff --git a/swh/web/auth/views.py b/swh/web/auth/views.py
index 7d0715e2..b662b977 100644
--- a/swh/web/auth/views.py
+++ b/swh/web/auth/views.py
@@ -1,191 +1,155 @@
# Copyright (C) 2020-2022 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
import json
from typing import Any, Dict, Union, cast
from cryptography.fernet import InvalidToken
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest
from django.http.response import (
HttpResponse,
HttpResponseBadRequest,
HttpResponseForbidden,
HttpResponseRedirect,
JsonResponse,
)
from django.shortcuts import render
-from django.urls import re_path as url
from django.views.decorators.http import require_http_methods
from swh.auth.django.models import OIDCUser
from swh.auth.django.utils import keycloak_oidc_client
from swh.auth.django.views import get_oidc_login_data, oidc_login_view
-from swh.auth.django.views import urlpatterns as auth_urlpatterns
from swh.auth.keycloak import KeycloakError, keycloak_error_message
from swh.web.auth.models import OIDCUserOfflineTokens
from swh.web.auth.utils import decrypt_data, encrypt_data
from swh.web.common.exc import ForbiddenExc
from swh.web.common.utils import reverse
from swh.web.config import get_config
def oidc_generate_bearer_token(request: HttpRequest) -> HttpResponse:
if not request.user.is_authenticated or not isinstance(request.user, OIDCUser):
return HttpResponseForbidden()
redirect_uri = reverse("oidc-generate-bearer-token-complete", request=request)
return oidc_login_view(
request, redirect_uri=redirect_uri, scope="openid offline_access"
)
def oidc_generate_bearer_token_complete(request: HttpRequest) -> HttpResponse:
if not request.user.is_authenticated or not isinstance(request.user, OIDCUser):
raise ForbiddenExc("You are not allowed to generate bearer tokens.")
if "error" in request.GET:
raise Exception(request.GET["error"])
login_data = get_oidc_login_data(request)
oidc_client = keycloak_oidc_client()
oidc_profile = oidc_client.authorization_code(
code=request.GET["code"],
code_verifier=login_data["code_verifier"],
redirect_uri=login_data["redirect_uri"],
)
user = cast(OIDCUser, request.user)
token = oidc_profile["refresh_token"]
secret = get_config()["secret_key"].encode()
salt = user.sub.encode()
encrypted_token = encrypt_data(token.encode(), secret, salt)
OIDCUserOfflineTokens.objects.create(
user_id=str(user.id), offline_token=encrypted_token
).save()
return HttpResponseRedirect(reverse("oidc-profile") + "#tokens")
def oidc_list_bearer_tokens(request: HttpRequest) -> HttpResponse:
if not request.user.is_authenticated or not isinstance(request.user, OIDCUser):
return HttpResponseForbidden()
tokens = OIDCUserOfflineTokens.objects.filter(user_id=str(request.user.id))
tokens = tokens.order_by("-creation_date")
length = int(request.GET["length"])
page = int(request.GET["start"]) / length + 1
paginator = Paginator(tokens, length)
tokens_data = [
{"id": t.id, "creation_date": t.creation_date.isoformat()}
for t in paginator.page(int(page)).object_list
]
table_data: Dict[str, Any] = {}
table_data["recordsTotal"] = len(tokens_data)
table_data["draw"] = int(request.GET["draw"])
table_data["data"] = tokens_data
table_data["recordsFiltered"] = len(tokens_data)
return JsonResponse(table_data)
def _encrypted_token_bytes(token: Union[bytes, memoryview]) -> bytes:
# token has been retrieved from a PosgreSQL database
if isinstance(token, memoryview):
return token.tobytes()
else:
return token
@require_http_methods(["POST"])
def oidc_get_bearer_token(request: HttpRequest) -> HttpResponse:
if not request.user.is_authenticated or not isinstance(request.user, OIDCUser):
return HttpResponseForbidden()
try:
data = json.loads(request.body.decode("ascii"))
user = cast(OIDCUser, request.user)
token_data = OIDCUserOfflineTokens.objects.get(id=data["token_id"])
secret = get_config()["secret_key"].encode()
salt = user.sub.encode()
decrypted_token = decrypt_data(
_encrypted_token_bytes(token_data.offline_token), secret, salt
)
refresh_token = decrypted_token.decode("ascii")
# check token is still valid
oidc_client = keycloak_oidc_client()
oidc_client.refresh_token(refresh_token)
return HttpResponse(refresh_token, content_type="text/plain")
except InvalidToken:
return HttpResponse(status=401)
except KeycloakError as ke:
error_msg = keycloak_error_message(ke)
if error_msg in (
"invalid_grant: Offline session not active",
"invalid_grant: Offline user session not found",
):
error_msg = "Bearer token has expired, please generate a new one."
return HttpResponseBadRequest(error_msg, content_type="text/plain")
@require_http_methods(["POST"])
def oidc_revoke_bearer_tokens(request: HttpRequest) -> HttpResponse:
if not request.user.is_authenticated or not isinstance(request.user, OIDCUser):
return HttpResponseForbidden()
try:
data = json.loads(request.body.decode("ascii"))
user = cast(OIDCUser, request.user)
for token_id in data["token_ids"]:
token_data = OIDCUserOfflineTokens.objects.get(id=token_id)
secret = get_config()["secret_key"].encode()
salt = user.sub.encode()
decrypted_token = decrypt_data(
_encrypted_token_bytes(token_data.offline_token), secret, salt
)
oidc_client = keycloak_oidc_client()
oidc_client.logout(decrypted_token.decode("ascii"))
token_data.delete()
return HttpResponse(status=200)
except InvalidToken:
return HttpResponse(status=401)
@login_required(login_url="/oidc/login/", redirect_field_name="next_path")
-def _oidc_profile_view(request: HttpRequest) -> HttpResponse:
- return render(request, "auth/profile.html")
-
-
-urlpatterns = auth_urlpatterns + [
- url(
- r"^oidc/generate-bearer-token/$",
- oidc_generate_bearer_token,
- name="oidc-generate-bearer-token",
- ),
- url(
- r"^oidc/generate-bearer-token-complete/$",
- oidc_generate_bearer_token_complete,
- name="oidc-generate-bearer-token-complete",
- ),
- url(
- r"^oidc/list-bearer-token/$",
- oidc_list_bearer_tokens,
- name="oidc-list-bearer-tokens",
- ),
- url(
- r"^oidc/get-bearer-token/$",
- oidc_get_bearer_token,
- name="oidc-get-bearer-token",
- ),
- url(
- r"^oidc/revoke-bearer-tokens/$",
- oidc_revoke_bearer_tokens,
- name="oidc-revoke-bearer-tokens",
- ),
- url(
- r"^oidc/profile/$",
- _oidc_profile_view,
- name="oidc-profile",
- ),
-]
+def oidc_profile_view(request: HttpRequest) -> HttpResponse:
+ return render(request, "profile.html")
diff --git a/swh/web/settings/common.py b/swh/web/settings/common.py
index a91d4644..76118cee 100644
--- a/swh/web/settings/common.py
+++ b/swh/web/settings/common.py
@@ -1,349 +1,349 @@
# Copyright (C) 2017-2022 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
"""
Django common settings for swh-web.
"""
from importlib.util import find_spec
import os
import sys
from typing import Any, Dict
from django.utils import encoding
from swh.web.auth.utils import OIDC_SWH_WEB_CLIENT_ID
from swh.web.config import get_config
# Fix django-js-reverse 0.9.1 compatibility with django 4.x
# TODO: Remove that hack once a new django-js-reverse release
# is available on PyPI
if not hasattr(encoding, "force_text"):
setattr(encoding, "force_text", encoding.force_str)
swh_web_config = get_config()
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = swh_web_config["secret_key"]
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = swh_web_config["debug"]
DEBUG_PROPAGATE_EXCEPTIONS = swh_web_config["debug"]
ALLOWED_HOSTS = ["127.0.0.1", "localhost"] + swh_web_config["allowed_hosts"]
# Application definition
SWH_BASE_DJANGO_APPS = [
"swh.web.auth",
"swh.web.browse",
"swh.web.common",
"swh.web.api",
]
SWH_EXTRA_DJANGO_APPS = [
app
for app in swh_web_config["swh_extra_django_apps"]
if app not in SWH_BASE_DJANGO_APPS
]
# swh.web.api must be the last loaded application due to the way
# its URLS are registered
SWH_DJANGO_APPS = SWH_EXTRA_DJANGO_APPS + SWH_BASE_DJANGO_APPS
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"webpack_loader",
"django_js_reverse",
"corsheaders",
] + SWH_DJANGO_APPS
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"swh.auth.django.middlewares.OIDCSessionExpiredMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"swh.web.common.middlewares.ThrottlingHeadersMiddleware",
"swh.web.common.middlewares.ExceptionMiddleware",
]
# Compress all assets (static ones and dynamically generated html)
# served by django in a local development environment context.
# In a production environment, assets compression will be directly
# handled by web servers like apache or nginx.
if swh_web_config["serve_assets"]:
MIDDLEWARE.insert(0, "django.middleware.gzip.GZipMiddleware")
ROOT_URLCONF = "swh.web.urls"
SWH_APP_TEMPLATES = [os.path.join(PROJECT_DIR, "../templates")]
# Add templates directory from each SWH Django application
for app in SWH_DJANGO_APPS:
try:
app_spec = find_spec(app)
assert app_spec is not None, f"Django application {app} not found !"
assert app_spec.origin is not None
SWH_APP_TEMPLATES.append(
os.path.join(os.path.dirname(app_spec.origin), "templates")
)
except ModuleNotFoundError:
assert False, f"Django application {app} not found !"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": SWH_APP_TEMPLATES,
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"swh.web.common.utils.context_processor",
],
"libraries": {
"swh_templatetags": "swh.web.common.swh_templatetags",
},
},
},
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": swh_web_config.get("development_db", ""),
}
}
# Password validation
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # noqa
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/1.11/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.11/howto/static-files/
STATIC_URL = "/static/"
# static folder location when swh-web has been installed with pip
STATIC_DIR = os.path.join(sys.prefix, "share/swh/web/static")
if not os.path.exists(STATIC_DIR):
# static folder location when developping swh-web
STATIC_DIR = os.path.join(PROJECT_DIR, "../../../static")
STATICFILES_DIRS = [STATIC_DIR]
INTERNAL_IPS = ["127.0.0.1"]
throttle_rates = {}
http_requests = ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"]
throttling = swh_web_config["throttling"]
for limiter_scope, limiter_conf in throttling["scopes"].items():
if "default" in limiter_conf["limiter_rate"]:
throttle_rates[limiter_scope] = limiter_conf["limiter_rate"]["default"]
# for backward compatibility
else:
throttle_rates[limiter_scope] = limiter_conf["limiter_rate"]
# register sub scopes specific for HTTP request types
for http_request in http_requests:
if http_request in limiter_conf["limiter_rate"]:
throttle_rates[limiter_scope + "_" + http_request.lower()] = limiter_conf[
"limiter_rate"
][http_request]
REST_FRAMEWORK: Dict[str, Any] = {
"DEFAULT_RENDERER_CLASSES": (
"rest_framework.renderers.JSONRenderer",
"swh.web.api.renderers.YAMLRenderer",
"rest_framework.renderers.TemplateHTMLRenderer",
),
"DEFAULT_THROTTLE_CLASSES": (
"swh.web.api.throttling.SwhWebRateThrottle",
"swh.web.api.throttling.SwhWebUserRateThrottle",
),
"DEFAULT_THROTTLE_RATES": throttle_rates,
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
"swh.auth.django.backends.OIDCBearerTokenAuthentication",
],
"EXCEPTION_HANDLER": "swh.web.api.apiresponse.error_response_handler",
}
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"filters": {
"require_debug_false": {
"()": "django.utils.log.RequireDebugFalse",
},
"require_debug_true": {
"()": "django.utils.log.RequireDebugTrue",
},
},
"formatters": {
"request": {
"format": "[%(asctime)s] [%(levelname)s] %(request)s %(status_code)s",
"datefmt": "%d/%b/%Y %H:%M:%S",
},
"simple": {
"format": "[%(asctime)s] [%(levelname)s] %(message)s",
"datefmt": "%d/%b/%Y %H:%M:%S",
},
"verbose": {
"format": (
"[%(asctime)s] [%(levelname)s] %(name)s.%(funcName)s:%(lineno)s "
"- %(message)s"
),
"datefmt": "%d/%b/%Y %H:%M:%S",
},
},
"handlers": {
"console": {
"level": "DEBUG",
"filters": ["require_debug_true"],
"class": "logging.StreamHandler",
"formatter": "simple",
},
"file": {
"level": "WARNING",
"filters": ["require_debug_false"],
"class": "logging.FileHandler",
"filename": os.path.join(swh_web_config["log_dir"], "swh-web.log"),
"formatter": "simple",
},
"file_request": {
"level": "WARNING",
"filters": ["require_debug_false"],
"class": "logging.FileHandler",
"filename": os.path.join(swh_web_config["log_dir"], "swh-web.log"),
"formatter": "request",
},
"console_verbose": {
"level": "DEBUG",
"filters": ["require_debug_true"],
"class": "logging.StreamHandler",
"formatter": "verbose",
},
"file_verbose": {
"level": "WARNING",
"filters": ["require_debug_false"],
"class": "logging.FileHandler",
"filename": os.path.join(swh_web_config["log_dir"], "swh-web.log"),
"formatter": "verbose",
},
"null": {
"class": "logging.NullHandler",
},
},
"loggers": {
"": {
"handlers": ["console_verbose", "file_verbose"],
"level": "DEBUG" if DEBUG else "WARNING",
},
"django": {
"handlers": ["console"],
"level": "DEBUG" if DEBUG else "WARNING",
"propagate": False,
},
"django.request": {
"handlers": ["file_request"],
"level": "DEBUG" if DEBUG else "WARNING",
"propagate": False,
},
"django.db.backends": {"handlers": ["null"], "propagate": False},
"django.utils.autoreload": {
"level": "INFO",
},
"swh.core.statsd": {
"level": "INFO",
},
"urllib3": {
"level": "INFO",
},
},
}
WEBPACK_LOADER = {
"DEFAULT": {
"CACHE": False,
"BUNDLE_DIR_NAME": "./",
"STATS_FILE": os.path.join(STATIC_DIR, "webpack-stats.json"),
"POLL_INTERVAL": 0.1,
"TIMEOUT": None,
"IGNORE": [".+\\.hot-update.js", ".+\\.map"],
}
}
-LOGIN_URL = "/admin/login/"
-LOGIN_REDIRECT_URL = "admin"
+LOGIN_URL = "/login/"
+LOGIN_REDIRECT_URL = "swh-web-homepage"
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
CACHES = {
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
}
JS_REVERSE_JS_MINIFY = False
CORS_ORIGIN_ALLOW_ALL = True
CORS_URLS_REGEX = r"^/(badge|api)/.*$"
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"swh.auth.django.backends.OIDCAuthorizationCodePKCEBackend",
]
SWH_AUTH_SERVER_URL = swh_web_config["keycloak"]["server_url"]
SWH_AUTH_REALM_NAME = swh_web_config["keycloak"]["realm_name"]
SWH_AUTH_CLIENT_ID = OIDC_SWH_WEB_CLIENT_ID
SWH_AUTH_SESSION_EXPIRED_REDIRECT_VIEW = "logout"
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
diff --git a/swh/web/tests/auth/test_views.py b/swh/web/tests/auth/test_views.py
index 4cd3edaf..2b194613 100644
--- a/swh/web/tests/auth/test_views.py
+++ b/swh/web/tests/auth/test_views.py
@@ -1,312 +1,312 @@
# Copyright (C) 2020-2021 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
import json
from urllib.parse import urljoin, urlparse
import uuid
import pytest
from django.http import QueryDict
from swh.auth.keycloak import KeycloakError
from swh.web.auth.models import OIDCUserOfflineTokens
from swh.web.auth.utils import OIDC_SWH_WEB_CLIENT_ID, decrypt_data
from swh.web.common.utils import reverse
from swh.web.config import get_config
from swh.web.tests.django_asserts import assert_contains
from swh.web.tests.utils import (
check_html_get_response,
check_http_get_response,
check_http_post_response,
)
from swh.web.urls import _default_view as homepage_view
def _check_oidc_login_code_flow_data(
request, response, keycloak_oidc, redirect_uri, scope="openid"
):
parsed_url = urlparse(response["location"])
authorization_url = keycloak_oidc.well_known()["authorization_endpoint"]
query_dict = QueryDict(parsed_url.query)
# check redirect url is valid
assert urljoin(response["location"], parsed_url.path) == authorization_url
assert "client_id" in query_dict
assert query_dict["client_id"] == OIDC_SWH_WEB_CLIENT_ID
assert "response_type" in query_dict
assert query_dict["response_type"] == "code"
assert "redirect_uri" in query_dict
assert query_dict["redirect_uri"] == redirect_uri
assert "code_challenge_method" in query_dict
assert query_dict["code_challenge_method"] == "S256"
assert "scope" in query_dict
assert query_dict["scope"] == scope
assert "state" in query_dict
assert "code_challenge" in query_dict
# check a login_data has been registered in user session
assert "login_data" in request.session
login_data = request.session["login_data"]
assert "code_verifier" in login_data
assert "state" in login_data
assert "redirect_uri" in login_data
assert login_data["redirect_uri"] == query_dict["redirect_uri"]
return login_data
def test_view_rendering_when_user_not_set_in_request(request_factory):
request = request_factory.get("/")
# Django RequestFactory do not set any user by default
assert not hasattr(request, "user")
response = homepage_view(request)
assert response.status_code == 200
def test_oidc_generate_bearer_token_anonymous_user(client):
"""
Anonymous user should be refused access with forbidden response.
"""
url = reverse("oidc-generate-bearer-token")
check_http_get_response(client, url, status_code=403)
def _generate_and_test_bearer_token(client, kc_oidc_mock):
# user authenticates
client.login(
code="code", code_verifier="code-verifier", redirect_uri="redirect-uri"
)
# user initiates bearer token generation flow
url = reverse("oidc-generate-bearer-token")
response = check_http_get_response(client, url, status_code=302)
request = response.wsgi_request
redirect_uri = reverse("oidc-generate-bearer-token-complete", request=request)
# check login data and redirection to Keycloak is valid
login_data = _check_oidc_login_code_flow_data(
request,
response,
kc_oidc_mock,
redirect_uri=redirect_uri,
scope="openid offline_access",
)
# once a user has identified himself in Keycloak, he is
# redirected to the 'oidc-generate-bearer-token-complete' view
# to get and save bearer token
# generate authorization code / session state in the same
# manner as Keycloak
code = f"{str(uuid.uuid4())}.{str(uuid.uuid4())}.{str(uuid.uuid4())}"
session_state = str(uuid.uuid4())
token_complete_url = reverse(
"oidc-generate-bearer-token-complete",
query_params={
"code": code,
"state": login_data["state"],
"session_state": session_state,
},
)
nb_tokens = len(OIDCUserOfflineTokens.objects.all())
response = check_http_get_response(client, token_complete_url, status_code=302)
request = response.wsgi_request
# check token has been generated and saved encrypted to database
assert len(OIDCUserOfflineTokens.objects.all()) == nb_tokens + 1
encrypted_token = OIDCUserOfflineTokens.objects.last().offline_token.tobytes()
secret = get_config()["secret_key"].encode()
salt = request.user.sub.encode()
decrypted_token = decrypt_data(encrypted_token, secret, salt)
oidc_profile = kc_oidc_mock.authorization_code(code=code, redirect_uri=redirect_uri)
assert decrypted_token.decode("ascii") == oidc_profile["refresh_token"]
# should redirect to tokens management Web UI
assert response["location"] == reverse("oidc-profile") + "#tokens"
return decrypted_token
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_oidc_generate_bearer_token_authenticated_user_success(client, keycloak_oidc):
"""
Authenticated user should be able to generate a bearer token using OIDC
Authorization Code Flow.
"""
_generate_and_test_bearer_token(client, keycloak_oidc)
def test_oidc_list_bearer_tokens_anonymous_user(client):
"""
Anonymous user should be refused access with forbidden response.
"""
url = reverse(
"oidc-list-bearer-tokens", query_params={"draw": 1, "start": 0, "length": 10}
)
check_http_get_response(client, url, status_code=403)
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_oidc_list_bearer_tokens(client, keycloak_oidc):
"""
User with correct credentials should be allowed to list his tokens.
"""
nb_tokens = 3
for _ in range(nb_tokens):
_generate_and_test_bearer_token(client, keycloak_oidc)
url = reverse(
"oidc-list-bearer-tokens", query_params={"draw": 1, "start": 0, "length": 10}
)
response = check_http_get_response(client, url, status_code=200)
tokens_data = list(reversed(json.loads(response.content.decode("utf-8"))["data"]))
for oidc_token in OIDCUserOfflineTokens.objects.all():
assert (
oidc_token.creation_date.isoformat()
== tokens_data[oidc_token.id - 1]["creation_date"]
)
def test_oidc_get_bearer_token_anonymous_user(client):
"""
Anonymous user should be refused access with forbidden response.
"""
url = reverse("oidc-get-bearer-token")
check_http_post_response(client, url, status_code=403)
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_oidc_get_bearer_token(client, keycloak_oidc):
"""
User with correct credentials should be allowed to display a token.
"""
nb_tokens = 3
for i in range(nb_tokens):
token = _generate_and_test_bearer_token(client, keycloak_oidc)
url = reverse("oidc-get-bearer-token")
response = check_http_post_response(
client,
url,
status_code=200,
data={"token_id": i + 1},
content_type="text/plain",
)
assert response.content == token
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_oidc_get_bearer_token_expired_token(client, keycloak_oidc):
"""
User with correct credentials should be allowed to display a token.
"""
_generate_and_test_bearer_token(client, keycloak_oidc)
for kc_err_msg in ("Offline session not active", "Offline user session not found"):
kc_error_dict = {
"error": "invalid_grant",
"error_description": kc_err_msg,
}
keycloak_oidc.refresh_token.side_effect = KeycloakError(
error_message=json.dumps(kc_error_dict).encode(), response_code=400
)
url = reverse("oidc-get-bearer-token")
response = check_http_post_response(
client,
url,
status_code=400,
data={"token_id": 1},
content_type="text/plain",
)
assert (
response.content == b"Bearer token has expired, please generate a new one."
)
def test_oidc_revoke_bearer_tokens_anonymous_user(client):
"""
Anonymous user should be refused access with forbidden response.
"""
url = reverse("oidc-revoke-bearer-tokens")
check_http_post_response(client, url, status_code=403)
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_oidc_revoke_bearer_tokens(client, keycloak_oidc):
"""
User with correct credentials should be allowed to revoke tokens.
"""
nb_tokens = 3
for _ in range(nb_tokens):
_generate_and_test_bearer_token(client, keycloak_oidc)
url = reverse("oidc-revoke-bearer-tokens")
check_http_post_response(
client,
url,
status_code=200,
data={"token_ids": [1]},
)
assert len(OIDCUserOfflineTokens.objects.all()) == 2
check_http_post_response(
client,
url,
status_code=200,
data={"token_ids": [2, 3]},
)
assert len(OIDCUserOfflineTokens.objects.all()) == 0
def test_oidc_profile_view_anonymous_user(client):
"""
Non authenticated users should be redirected to login page when
requesting profile view.
"""
url = reverse("oidc-profile")
login_url = reverse("oidc-login", query_params={"next_path": url})
resp = check_http_get_response(client, url, status_code=302)
assert resp["location"] == login_url
@pytest.mark.django_db(transaction=True, reset_sequences=True)
def test_oidc_profile_view(client, keycloak_oidc):
"""
Authenticated users should be able to request the profile page
and link to Keycloak account UI should be present.
"""
url = reverse("oidc-profile")
kc_config = get_config()["keycloak"]
client_permissions = ["perm1", "perm2"]
keycloak_oidc.client_permissions = client_permissions
client.login(code="", code_verifier="", redirect_uri="")
resp = check_html_get_response(
- client, url, status_code=200, template_used="auth/profile.html"
+ client, url, status_code=200, template_used="profile.html"
)
user = resp.wsgi_request.user
kc_account_url = (
f"{kc_config['server_url']}realms/{kc_config['realm_name']}/account/"
)
assert_contains(resp, kc_account_url)
assert_contains(resp, user.username)
assert_contains(resp, user.first_name)
assert_contains(resp, user.last_name)
assert_contains(resp, user.email)
for perm in client_permissions:
assert_contains(resp, perm)
diff --git a/swh/web/urls.py b/swh/web/urls.py
index 72687d73..9f724413 100644
--- a/swh/web/urls.py
+++ b/swh/web/urls.py
@@ -1,86 +1,83 @@
# Copyright (C) 2017-2022 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
from importlib.util import find_spec
from django_js_reverse.views import urls_js
from django.conf import settings
from django.conf.urls import handler400, handler403, handler404, handler500, include
-from django.contrib.auth.views import LogoutView
from django.contrib.staticfiles.views import serve
from django.shortcuts import render
from django.urls import re_path as url
from django.views.generic.base import RedirectView
from swh.web.browse.identifiers import swhid_browse
from swh.web.common.exc import (
swh_handle400,
swh_handle403,
swh_handle404,
swh_handle500,
)
from swh.web.common.utils import origin_visit_types
from swh.web.config import get_config
swh_web_config = get_config()
favicon_view = RedirectView.as_view(
url="/static/img/icons/swh-logo-32x32.png", permanent=True
)
def _default_view(request):
return render(request, "homepage.html", {"visit_types": origin_visit_types()})
urlpatterns = [
url(r"^admin/", include("swh.web.admin.urls")),
url(r"^favicon\.ico/$", favicon_view),
url(r"^$", _default_view, name="swh-web-homepage"),
url(r"^jsreverse/$", urls_js, name="js_reverse"),
# keep legacy SWHID resolving URL with trailing slash for backward compatibility
url(
r"^(?P<swhid>(swh|SWH):[0-9]+:[A-Za-z]+:[0-9A-Fa-f]+.*)/$",
swhid_browse,
name="browse-swhid-legacy",
),
url(
r"^(?P<swhid>(swh|SWH):[0-9]+:[A-Za-z]+:[0-9A-Fa-f]+.*)$",
swhid_browse,
name="browse-swhid",
),
url(r"^", include("swh.web.misc.urls")),
- url(r"^", include("swh.web.auth.views")),
- url(r"^logout/$", LogoutView.as_view(template_name="logout.html"), name="logout"),
]
# Register URLs for each SWH Django application
for app in settings.SWH_DJANGO_APPS:
app_urls = app + ".urls"
try:
app_urls_spec = find_spec(app_urls)
if app_urls_spec is not None:
urlpatterns.append(url(r"^", include(app_urls)))
except ModuleNotFoundError:
assert False, f"Django application {app} not found !"
# allow to serve assets through django staticfiles
# even if settings.DEBUG is False
def insecure_serve(request, path, **kwargs):
return serve(request, path, insecure=True, **kwargs)
# enable to serve compressed assets through django development server
if swh_web_config["serve_assets"]:
static_pattern = r"^%s(?P<path>.*)/$" % settings.STATIC_URL[1:]
urlpatterns.append(url(static_pattern, insecure_serve))
handler400 = swh_handle400 # noqa
handler403 = swh_handle403 # noqa
handler404 = swh_handle404 # noqa
handler500 = swh_handle500 # noqa
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Jun 21, 6:30 PM (1 w, 6 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3247203
Attached To
rDWAPPS Web applications
Event Timeline
Log In to Comment