diff --git a/CHANGELOG.md b/CHANGELOG.md index 927edb9..adeca46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,25 @@ See also [Creating new releases] for instructions on how to create a new release ### Added - Added REST API endpoints for print previews [#178] - Added configurable printer display order in the Django admin panel. [#181] +<<<<<<< HEAD - Added configuration environment variables used when Gutenberg is deployed behind a reverse proxy: - `GUTENBERG_TRUST_X_FORWARDED_HOST`, `GUTENBERG_TRUST_X_FORWARDED_PROTO` and `GUTENBERG_TRUST_X_REAL_IP` [#175] - `GUTENBERG_TRUSTED_PROXY_IPS` [#190] - Docker Images are now published in the GitHub Container Registry [#199] +======= +- Modified nginx Docker image config to correctly pass the `X-Forwarded-Host` header [#175] +- Changed default nginx location filenames [#175] +- Added `GUTENBERG_TRUST_X_FORWARDED_HOST`, `GUTENBERG_TRUST_X_FORWARDED_PROTO` and `GUTENBERG_TRUST_X_REAL_IP` environment variables used to configure the nginx container [#175] +### Added +- Added `GUTENBERG_TRUSTED_PROXY_IPS` environment variable for configuring trusted reverse proxy IP addresses when using `GUTENBERG_TRUST_X_FORWARDED_*` or `GUTENBERG_TRUST_X_REAL_IP` variables [#190] +- Added 4 test print buttons to the Printer admin page. Buttons: Simplex white and black, simplex color,duplex black and white, duplex color.[#196] +### Security +- Nginx now requires explicit `GUTENBERG_TRUSTED_PROXY_IPS` configuration when `GUTENBERG_TRUST_X_FORWARDED_*` or `GUTENBERG_TRUST_X_REAL_IP` variables are enabled, preventing `X-Forwarded-*` header injection attacks [#190] +### Added +- Added `GUTENBERG_TRUST_X_FORWARDED_HOST`, `GUTENBERG_TRUST_X_FORWARDED_PROTO` and `GUTENBERG_TRUST_X_REAL_IP` environment variables used to configure the nginx container [#175] +- Added asynchronous REST API endpoints for generating, retrieving and canceling print previews [#178] +- Added preview status, page metadata and print-job configuration version tracking [#178] +>>>>>>> c62f3aa (feature:add test print button using JS fetch and refactor print services) ### Changed - Modified nginx Docker image config to correctly pass the `X-Forwarded-Host` header [#175] @@ -75,7 +90,10 @@ The previous significant commit was made on [2022-08-26](https://github.com/KSIU [#178]: https://github.com/KSIUJ/gutenberg/pull/178 [#190]: https://github.com/KSIUJ/gutenberg/pull/190 [#194]: https://github.com/KSIUJ/gutenberg/pull/194 -- [#199]: https://github.com/KSIUJ/gutenberg/pull/199 +[#196]: https://github.com/KSIUJ/gutenberg/pull/196 +[#199]: https://github.com/KSIUJ/gutenberg/pull/199 + + [keep a changelog]: https://keepachangelog.com/en/1.1.0/ [OpenID Connect chapter]: https://ksiuj.github.io/gutenberg/admin/openid-connect.html diff --git a/backend/api/views.py b/backend/api/views.py index fdaa9b4..d4dd851 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -2,7 +2,12 @@ import os import tempfile from secrets import token_urlsafe - +from django.core.exceptions import ValidationError, ObjectDoesNotExist +from printing.services import ( + create_printing_job as create_printing_job_helper, + validate_properties as validate_properties_helper, + run_job as run_job_helper, +) from celery import current_app from django.contrib.auth import authenticate, login from django.db import transaction @@ -164,25 +169,26 @@ def _create_printing_job( orientation_requested: str, **_, ): - with transaction.atomic(): - job = GutenbergJob(name='webrequest', job_type=JobType.PRINT, status=JobStatus.INCOMING, - owner=self.request.user, printer=printer_with_perms) - job.properties = PrintingProperties( - color=color, + # Delegation to services.py + try: + job = create_printing_job_helper( + user=self.request.user, + printer_with_perms=printer_with_perms, + name='webrequest', copies=copies, + pages_to_print=pages_to_print, + color=color, two_sides=two_sides, - pages_to_print=None if pages_to_print == "" else pages_to_print, - job=job, fit_to_page=fit_to_page, n_up=n_up, imposition_template=imposition_template, orientation_requested=orientation_requested, ) - - self._validate_properties(printer_with_perms.id, job.properties, job) - job.save() - job.properties.save() return job + except ObjectDoesNotExist: + raise exceptions.NotFound("Selected printer does not exist") + except ValidationError as ex: + raise exceptions.ValidationError(detail=getattr(ex, 'messages', [str(ex)])) def _change_properties( self, @@ -279,39 +285,15 @@ def _change_order(self, new_order): return job def _run_job(self, job): - try: - preview = job.preview - except PrintPreview.DoesNotExist: - preview = None - - if preview is not None: - if preview.status in (PreviewStatus.PENDING, PreviewStatus.PROCESSING): - if preview.celery_task_id: - current_app.control.revoke( - preview.celery_task_id, - terminate=False, - ) - preview.status = PreviewStatus.CANCELED - preview.save(update_fields=['status', 'updated_at']) - - job.status = JobStatus.PENDING - job.save() - print_file.delay(job.id) - logger.info('User %s submitted job: %s', self.request.user.username, job.id) - return job + return run_job_helper(job, request_user=self.request.user) def _validate_properties(self, printer_id: int, properties, job): - if job.status != JobStatus.INCOMING: - raise InvalidStatus("Invalid job status for this request", additional_info="current status: {}".format(job.status)) - printer_with_perms = Printer.get_printer_for_user(user=self.request.user, - printer_id=printer_id) - if not printer_with_perms: + try: + validate_properties_helper(user=self.request.user, printer_id=printer_id, properties=properties, job=job) + except ObjectDoesNotExist: raise exceptions.NotFound("Selected printer does not exist") - if properties.color and not printer_with_perms.color_allowed: - raise exceptions.ValidationError("Color printing is not allowed on the selected printer") - if properties.two_sides != TwoSidedPrinting.ONE_SIDED and not printer_with_perms.duplex_supported: - raise exceptions.ValidationError("Two-sided printing is not supported on the selected printer") - + except ValidationError as ex: + raise exceptions.ValidationError(detail=getattr(ex, 'messages', [str(ex)])) @action( detail=True, methods=['get'], diff --git a/backend/control/admin.py b/backend/control/admin.py index 52c70df..6ad6585 100644 --- a/backend/control/admin.py +++ b/backend/control/admin.py @@ -1,10 +1,12 @@ from django.contrib import admin +from django.urls import path, reverse +from django.utils.html import format_html from control.forms import LocalPrinterParamsForm -# Register your models here. -from control.models import GutenbergJob, PrintingProperties, PrinterPermissions, LocalPrinterParams, Printer, \ - JobArtefact - +from control.models import GutenbergJob, PrintingProperties, PrinterPermissions, \ + LocalPrinterParams, Printer, JobArtefact +from control.views import trigger_test_print_view +from django.utils.safestring import mark_safe class PrintingPropertiesInline(admin.TabularInline): model = PrintingProperties @@ -32,8 +34,63 @@ class PrinterPermissionsAdmin(admin.TabularInline): class PrinterAdmin(admin.ModelAdmin): inlines = [LocalPrinterParamsInline, PrinterPermissionsAdmin] - list_display = ('name', 'display_order') + list_display = ('name', 'display_order','test_print_controls') ordering = ('display_order', 'name') + readonly_fields = ('test_print_controls',) + + def get_urls(self): + """Returns HTML for Django admin list view""" + urls = super().get_urls() + custom_urls = [ + path( + '/test-print/', + self.admin_site.admin_view(trigger_test_print_view), + name='control_printer_test_print', + ), + ] + return custom_urls + urls + + def test_print_controls(self, obj): + """ + Renders action buttons for test prints in different configurations (one-sided/two-sided, colored/grayscale). + """ + if not obj or not obj.pk: + return "-" + + url = reverse('admin:control_printer_test_print', args=[obj.pk]) + + test_variants = [ + ('Grayscale one-sided', False, False, 'btn-grayscale-simplex-one-sided'), + ('Colored one-sided', True, False, 'btn-colored-one-sided'), + ('Grayscale two-sided', False, True, 'btn-grayscale-two-sided'), + ('Colored two-sided', True, True, 'btn-colored-two-sided'), + ] + + buttons_html = '
' + + for label, color, duplex, css_class in test_variants: + btn_html = ( + f'' + ) + buttons_html += btn_html + + buttons_html += '
' + + return mark_safe(buttons_html) + + test_print_controls.short_description = 'Test Print Options' + + class Media: + """Class for loading js script""" + js = ('js/admin_test_print.js',) admin.site.register(Printer, PrinterAdmin) diff --git a/backend/control/models.py b/backend/control/models.py index 943d39d..65e8491 100644 --- a/backend/control/models.py +++ b/backend/control/models.py @@ -55,8 +55,8 @@ class OrientationRequested(models.TextChoices): class Printer(models.Model): name = models.CharField(max_length=64) printer_type = models.CharField(max_length=10, default=PrinterType.DISABLED, choices=PrinterType.choices) - color_supported = models.BooleanField(default=False) - duplex_supported = models.BooleanField(default=False) + color_supported = models.BooleanField(default=False,verbose_name="Colored printing supported") + duplex_supported = models.BooleanField(default=False, verbose_name="Two-sided printing supported") display_order = models.PositiveIntegerField( default=0, help_text="Printers are displayed in ascending order. Lower values appear first. The first printer in the list is used as the default." diff --git a/backend/control/static/documents/test_page_colored.pdf b/backend/control/static/documents/test_page_colored.pdf new file mode 100644 index 0000000..2735c2c Binary files /dev/null and b/backend/control/static/documents/test_page_colored.pdf differ diff --git a/backend/control/static/documents/test_page_grayscale.pdf b/backend/control/static/documents/test_page_grayscale.pdf new file mode 100644 index 0000000..c98d622 Binary files /dev/null and b/backend/control/static/documents/test_page_grayscale.pdf differ diff --git a/backend/control/static/js/admin_test_print.js b/backend/control/static/js/admin_test_print.js new file mode 100644 index 0000000..e564e75 --- /dev/null +++ b/backend/control/static/js/admin_test_print.js @@ -0,0 +1,86 @@ +/** + * Admin Test Print Handler + * + * Handles click events for test print buttons in the Django Admin interface. + * Sends a POST request with a JSON payload containing 'color' and 'duplex' parameters. + */ + +document.addEventListener('DOMContentLoaded', function() { + // Select all test print action buttons + const testPrintButtons = document.querySelectorAll('.admin-test-print-btn'); + + /** + * Helper function to retrieve a cookie value by name (used for CSRF token). + * + * @param {string} name - The name of the cookie. + * @returns {string|null} The cookie value or null if not found. + */ + function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; + } + + testPrintButtons.forEach(button => { + button.addEventListener('click', function(event) { + event.preventDefault(); + event.stopPropagation(); + + const url = this.getAttribute('data-url'); + const color = this.getAttribute('data-color') === 'true'; + const duplex = this.getAttribute('data-duplex') === 'true'; + + const csrfInput = document.querySelector('[name=csrfmiddlewaretoken]'); + const csrftoken = csrfInput ? csrfInput.value : getCookie('csrftoken'); + + const payload = { + color: color, + duplex: duplex + }; + + console.log('Sending test print request:', { url, payload }); + + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken + }, + body: JSON.stringify(payload) + }) + .then(response => { + console.log('Response status:', response.status); + return response.json(); + }) + .then(data => { + console.log('Response data:', data); + + if (data.status === 'ok') { + // Success — display alert and reload page + alert(`Test print job #${data.job_id} successfully dispatched!`); + setTimeout(() => { + window.location.reload(); + }, 500); + } else { + // Error response — display error details + const errorMessage = data.message || 'Unknown error'; + const details = data.details ? `\n\nDetails: ${data.details}` : ''; + alert(`Test print failed: ${errorMessage}${details}`); + } + }) + .catch(error => { + console.error('Error during test print request:', error); + alert(`Network error: ${error.message}`); + }); + }); + }); +}); diff --git a/backend/control/views.py b/backend/control/views.py index 91ea44a..d9a1aa6 100644 --- a/backend/control/views.py +++ b/backend/control/views.py @@ -1,3 +1,88 @@ -from django.shortcuts import render +from django.contrib import messages +from django.contrib.admin.views.decorators import staff_member_required +from django.views.decorators.http import require_POST +from django.core.exceptions import ValidationError +from django.shortcuts import get_object_or_404 +from django.http import JsonResponse +import logging +import json -# Create your views here. +from control.models import Printer +from printing.services import trigger_test_print_from_file, find_test_pdf_from_static + +logger = logging.getLogger('gutenberg.admin.test_print') + + +@staff_member_required +@require_POST +def trigger_test_print_view(request, printer_id): + """ + Triggers a test print for the selected printer. + Accepts JSON body with 'color' (bool) and 'duplex' (bool) parameters. + Returns a JSON response with status 'ok' and ID of the job if it succeeds + or status 'error' if it fails . + """ + logger.info( + "trigger_test_print_view called: method=%s referer=%s remote_addr=%s user=%s", + request.method, + request.META.get('HTTP_REFERER'), + request.META.get('REMOTE_ADDR'), + getattr(request.user, 'username', '')) + try: + data = json.loads(request.body) + except json.JSONDecodeError: + logger.warning("Invalid JSON in request body from user %s", + getattr(request.user, 'username', '')) + return JsonResponse( + {"status": "error", "message": "Invalid JSON payload"}, + status=400 + ) + + color = data.get('color', False) + duplex = data.get('duplex', False) + + if not isinstance(color, bool) or not isinstance(duplex, bool): + logger.warning( + "Invalid parameter types: color=%s (type %s), duplex=%s (type %s)", + color, type(color).__name__, duplex, type(duplex).__name__) + return JsonResponse( + {"status": "error", + "message": "Parameters 'color' and 'duplex' must be boolean"}, + status=400 + ) + + logger.info("Test print requested: color=%s, duplex=%s, printer_id=%s", + color, duplex, printer_id) + + printer = get_object_or_404(Printer, pk=printer_id) + pdf_filename = 'documents/test_page_colored.pdf' if color else 'documents/test_page_grayscale.pdf' + pdf_path = find_test_pdf_from_static(pdf_filename) + + if not pdf_path: + messages.error(request, + f"Test PDF file '{pdf_filename}' was not found in static assets.") + return JsonResponse({"status": "error", "message": "PDF not found"}, status=404) + + try: + job = trigger_test_print_from_file( + printer=printer, + user=request.user, + file_path=pdf_path, + color=color, + duplex=duplex + ) + msg = f"Test print job #{job.id} (color={color}, duplex={duplex}) dispatched to '{printer.name}'." + messages.success(request, msg) + logger.info(msg) + except ValidationError as e: + msgs = getattr(e, 'messages', [str(e)]) + messages.error(request, "; ".join(msgs)) + return JsonResponse({"status": "error", "message": "Validation failed"}, + status=400) + except Exception as e: + logger.exception("Error while triggering test print for printer %s: %s", + printer_id, e) + messages.error(request, f"Error triggering test print: {str(e)}") + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + return JsonResponse({"status": "ok", "job_id": job.id}) diff --git a/backend/printing/services.py b/backend/printing/services.py new file mode 100644 index 0000000..d720f83 --- /dev/null +++ b/backend/printing/services.py @@ -0,0 +1,172 @@ +import os +import logging +from django.db import transaction +from django.core.exceptions import ValidationError, ObjectDoesNotExist +from django.contrib.staticfiles.finders import find +from django.core.files import File +from celery import current_app + +from control.models import ( + GutenbergJob, + PrintingProperties, + JobArtefact, + Printer, + JobType, + JobStatus, + JobArtefactType, + TwoSidedPrinting, + PrintPreview, + PreviewStatus, + PrinterType, +) +from printing.printing import print_file + +logger = logging.getLogger('gutenberg.printing.services') + + +def validate_properties(user, printer_id: int, properties, job=None): + """ + Validates printing properties for the given user and printer id. + Raises: + - ObjectDoesNotExist if printer is not available to user + - ValidationError for property issues (color/duplex not allowed) + Returns: + printer_with_perms (Printer) + """ + printer_with_perms = Printer.get_printer_for_user(user=user, printer_id=printer_id) + if not printer_with_perms: + raise ObjectDoesNotExist("Selected printer does not exist") + + if properties.color and not getattr(printer_with_perms, 'color_allowed', False): + raise ValidationError("Color printing is not allowed on the selected printer") + + if properties.two_sides != TwoSidedPrinting.ONE_SIDED and not printer_with_perms.duplex_supported: + raise ValidationError("Two-sided printing is not supported on the selected printer") + + return printer_with_perms + + +def create_printing_job(user, + printer_with_perms: Printer, + *, + name: str = 'webrequest', + copies: int = 1, + pages_to_print: str = None, + color: bool = False, + two_sides: str = TwoSidedPrinting.ONE_SIDED, + fit_to_page: bool = True, + n_up: int = 1, + imposition_template: str = None, + orientation_requested: str = None): + """ + Create GutenbergJob and associated PrintingProperties (job status = INCOMING). + Returns created job. + """ + with transaction.atomic(): + job = GutenbergJob( + name=name, + job_type=JobType.PRINT, + status=JobStatus.INCOMING, + owner=user, + printer=printer_with_perms, + ) + job.properties = PrintingProperties( + color=color, + copies=copies, + two_sides=two_sides, + pages_to_print=None if pages_to_print == "" else pages_to_print, + job=job, + fit_to_page=fit_to_page, + n_up=n_up, + imposition_template=imposition_template or PrintingProperties._meta.get_field('imposition_template').get_default(), + orientation_requested=orientation_requested or PrintingProperties._meta.get_field('orientation_requested').get_default(), + ) + + validate_properties(user=user, printer_id=printer_with_perms.id, properties=job.properties, job=job) + + job.save() + job.properties.save() + return job + + +def run_job(job, request_user=None): + """ + Cancels any pending preview task, sets job status to PENDING, + and enqueues print_file task via Celery. + """ + try: + preview = job.preview + except PrintPreview.DoesNotExist: + preview = None + + if preview is not None: + if preview.status in (PreviewStatus.PENDING, PreviewStatus.PROCESSING): + if preview.celery_task_id: + try: + current_app.control.revoke(preview.celery_task_id, terminate=False) + except Exception: + logger.exception("Failed to revoke previous preview task %s", preview.celery_task_id) + preview.status = PreviewStatus.CANCELED + preview.save(update_fields=['status', 'updated_at']) + + job.status = JobStatus.PENDING + job.save(update_fields=['status']) + print_file.delay(job.id) + + if request_user: + logger.info('User %s submitted job: %s', getattr(request_user, 'username', ''), job.id) + else: + logger.info('Job submitted (no user): %s', job.id) + + return job + + +def trigger_test_print_from_file(printer: Printer, user, file_path: str, *, color: bool = False, duplex: bool = False): + """ + Creates and enqueues a test print job for the given printer using a PDF file. + Returns the created job. + """ + if getattr(printer, 'printer_type', None) not in (PrinterType.LOCAL_CUPS, 'LP',): + raise ValidationError("Test print is only supported for local CUPS printers (type LP).") + + if color and not printer.color_supported: + raise ValidationError(f"Printer '{printer.name}' does not support color printing.") + if duplex and not printer.duplex_supported: + raise ValidationError(f"Printer '{printer.name}' does not support duplex printing.") + + if not os.path.exists(file_path): + raise ValidationError(f"Test document not found: {file_path}") + + two_sided_option = TwoSidedPrinting.TWO_SIDED_LONG_EDGE if duplex else TwoSidedPrinting.ONE_SIDED + + job = create_printing_job( + user=user, + printer_with_perms=printer, + name=f"Admin Test Print - {printer.name}", + copies=1, + pages_to_print=None, + color=color, + two_sides=two_sided_option, + fit_to_page=True, + n_up=1, + ) + + with open(file_path, 'rb') as f: + JobArtefact.objects.create( + job=job, + file=File(f, name=os.path.basename(file_path)), + artefact_type=JobArtefactType.SOURCE, + mime_type='application/pdf', + document_number=1, + ) + + run_job(job, request_user=user) + return job + + +def find_test_pdf_from_static(relative_static_path='documents/test_page.pdf'): + """ + Uses staticfiles finder to locate the PDF. Returns absolute path or None. + """ + path = find(relative_static_path) + return path