├── .dockerignore ├── .gitattributes ├── .gitignore ├── LICENSE.txt ├── Makefile ├── README.md ├── cap-level0.toml ├── cap-max.toml ├── compose.build.yml ├── compose.override-example.yml ├── compose.yml ├── docker ├── Dockerfile ├── Dockerfile-alpine ├── README.md ├── demo-rais-entry.sh ├── demo-web-entry.sh ├── hub.md ├── images │ ├── jp2tests │ │ ├── 16-bit-gray.jp2 │ │ ├── 16-bit-rgb.jp2 │ │ └── sn00063609-19091231.jp2 │ └── testfile │ │ ├── test-world-link.jp2 │ │ ├── test-world.jp2 │ │ └── test-world.jp2-info.json ├── nginx.conf ├── s3demo │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── admin.go.html │ ├── asset.go.html │ ├── compose.yml │ ├── env-example │ ├── go.mod │ ├── go.sum │ ├── index.go.html │ ├── layout.go.html │ ├── main.go │ ├── nginx.conf │ └── server.go └── static │ ├── index.html │ ├── osd │ ├── images │ │ ├── fullpage_grouphover.png │ │ ├── fullpage_hover.png │ │ ├── fullpage_pressed.png │ │ ├── fullpage_rest.png │ │ ├── home_grouphover.png │ │ ├── home_hover.png │ │ ├── home_pressed.png │ │ ├── home_rest.png │ │ ├── next_grouphover.png │ │ ├── next_hover.png │ │ ├── next_pressed.png │ │ ├── next_rest.png │ │ ├── previous_grouphover.png │ │ ├── previous_hover.png │ │ ├── previous_pressed.png │ │ ├── previous_rest.png │ │ ├── rotateleft_grouphover.png │ │ ├── rotateleft_hover.png │ │ ├── rotateleft_pressed.png │ │ ├── rotateleft_rest.png │ │ ├── rotateright_grouphover.png │ │ ├── rotateright_hover.png │ │ ├── rotateright_pressed.png │ │ ├── rotateright_rest.png │ │ ├── zoomin_grouphover.png │ │ ├── zoomin_hover.png │ │ ├── zoomin_pressed.png │ │ ├── zoomin_rest.png │ │ ├── zoomout_grouphover.png │ │ ├── zoomout_hover.png │ │ ├── zoomout_pressed.png │ │ └── zoomout_rest.png │ └── openseadragon.min.js │ └── template.html ├── go.mod ├── go.sum ├── gocutus.png ├── rais-example.toml ├── revive.toml ├── rh_config ├── init.sh └── rais.service ├── scripts ├── buildrun.sh ├── can_cgo.sh ├── deploy.sh ├── dev.sh └── s3list.go └── src ├── cmd ├── jp2info │ └── main.go └── rais-server │ ├── admin_handlers.go │ ├── cache.go │ ├── config.go │ ├── encode.go │ ├── errors.go │ ├── headers.go │ ├── image_handler.go │ ├── image_handler_test.go │ ├── image_info.go │ ├── internal │ ├── servers │ │ └── servers.go │ └── statusrecorder │ │ └── statusrecorder.go │ ├── main.go │ ├── main_test.go │ ├── middleware.go │ ├── plugins.go │ ├── register.go │ └── stats.go ├── fakehttp ├── response_writer.go └── response_writer_test.go ├── iiif ├── feature_levels.go ├── feature_support.go ├── features.go ├── features_test.go ├── format.go ├── format_test.go ├── iiif.go ├── info.go ├── info_test.go ├── quality.go ├── region.go ├── region_test.go ├── rotation.go ├── rotation_test.go ├── size.go ├── size_test.go ├── url.go └── url_test.go ├── img ├── cloud_stream.go ├── cloud_stream_test.go ├── constraint.go ├── decoder.go ├── errors.go ├── file_stream.go ├── resource.go ├── resource_test.go └── streamer.go ├── jp2info ├── info.go └── scanner.go ├── openjpeg ├── decode.go ├── handlers.c ├── handlers.h ├── image_stream.go ├── jp2_image.go ├── jp2_image_test.go ├── jp2_resources.go ├── logging.go ├── progression_level.go ├── progression_level_test.go ├── stream.c └── stream.h ├── plugins ├── datadog │ └── main.go ├── imagick-decoder │ ├── convert.go │ ├── image.go │ ├── magick.c │ ├── magick.h │ ├── main.go │ └── resources.go ├── json-tracer │ ├── main.go │ ├── sr.go │ ├── tracer.go │ └── write.go └── plugins.go ├── transform ├── generator.go ├── rotation.go └── template.txt └── version └── version.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | /Dockerfile 3 | /docker/images/ 4 | /bin 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.go diff=golang 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | /docker/images 3 | !/docker/images/testfile/test-world* 4 | /.env 5 | /docker-compose.override.yml 6 | /compose.override.yml 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile directory 2 | MakefileDir := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) 3 | 4 | .PHONY: all generate binaries test format lint clean distclean docker 5 | 6 | BUILD := $(shell git describe --tags) 7 | 8 | # Default target builds binaries 9 | all: cgo binaries 10 | 11 | # Security check 12 | .PHONY: audit 13 | audit: 14 | go tool govulncheck ./src/... 15 | 16 | .PHONY: cgo 17 | cgo: 18 | ./scripts/can_cgo.sh 19 | 20 | # Generated code 21 | generate: src/transform/rotation.go 22 | 23 | src/transform/rotation.go: src/transform/generator.go src/transform/template.txt 24 | go run src/transform/generator.go 25 | go fmt src/transform/rotation.go 26 | 27 | # Binary building rules 28 | binaries: src/transform/rotation.go rais-server jp2info bin/plugins/json-tracer.so 29 | 30 | rais-server: 31 | go build -ldflags="-s -w -X rais/src/version.Version=$(BUILD)" -o ./bin/rais-server rais/src/cmd/rais-server 32 | 33 | jp2info: 34 | go build -ldflags="-s -w -X rais/src/version.Version=$(BUILD)" -o ./bin/jp2info rais/src/cmd/jp2info 35 | 36 | # Testing 37 | test: 38 | go test rais/src/... 39 | 40 | bench: 41 | go test -bench=. -benchtime=5s -count=2 rais/src/openjpeg rais/src/cmd/rais-server 42 | 43 | format: 44 | find src/ -name "*.go" | xargs gofmt -l -w -s 45 | 46 | lint: 47 | go tool revive src/... 48 | go vet rais/src/... 49 | 50 | # Cleanup 51 | clean: 52 | rm -rf bin/ 53 | rm -f src/transform/rotation.go 54 | 55 | distclean: clean 56 | go clean -modcache -testcache -cache 57 | docker rmi uolibraries/rais:build || true 58 | docker rmi uolibraries/rais:build-alpine || true 59 | docker rmi uolibraries/rais:dev || true 60 | docker rmi uolibraries/rais:dev-alpine || true 61 | 62 | # Generate the docker images 63 | docker: | generate 64 | docker pull golang:1 65 | docker pull golang:1-alpine 66 | docker build --rm --target build -f $(MakefileDir)/docker/Dockerfile -t rais:build $(MakefileDir) 67 | docker build --rm -f $(MakefileDir)/docker/Dockerfile -t uolibraries/rais:dev $(MakefileDir) 68 | make docker-alpine 69 | 70 | # Build just the alpine image for cases where we want to get this updated / cranked out fast 71 | docker-alpine: | generate 72 | docker build --rm --target build -f $(MakefileDir)/docker/Dockerfile-alpine -t rais:build-alpine $(MakefileDir) 73 | docker build --rm -f $(MakefileDir)/docker/Dockerfile-alpine -t uolibraries/rais:dev-alpine $(MakefileDir) 74 | 75 | # Build plugins on any change to their directory or their go files 76 | bin/plugins/%.so : src/plugins/% src/plugins/%/*.go 77 | go build -ldflags="-s -w" -buildmode=plugin -o $@ rais/$< 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Report Card](https://goreportcard.com/badge/github.com/uoregon-libraries/rais-image-server)](https://goreportcard.com/report/github.com/uoregon-libraries/rais-image-server) 2 | 3 | Rodent-Assimilated Image Server 4 | ======= 5 | 6 | ![Gocutus, the RAIS mascot](gocutus.png?raw=true "Gocutus, the RAIS mascot") 7 | 8 | RAIS was originally built by [eikeon](https://github.com/eikeon) as a 100% open 9 | source, no-commercial-products-required, proof-of-concept tile server for JP2 10 | images within [chronam](https://github.com/LibraryOfCongress/chronam). 11 | 12 | It has been updated to allow more command-line options, more source file 13 | formats, more features, and conformance to the [IIIF](http://iiif.io/) spec. 14 | 15 | RAIS is very efficient, completely free, and easy to set up and run. See our 16 | [wiki](https://github.com/uoregon-libraries/rais-image-server/wiki) pages for 17 | more details and documentation. 18 | 19 | Configuration 20 | ----- 21 | 22 | ### Main Configuration Settings 23 | 24 | RAIS uses a configuration system that allows environment variables, a config 25 | file, and/or command-line flags. See [rais-example.toml](rais-example.toml) 26 | for an example of a configuration file. RAIS will use a configuration 27 | file if one exists at `/etc/rais.toml`. 28 | 29 | The configuration file's values can be overridden by environment variables, 30 | while command-line flags will override both configuration files and 31 | environmental variables. Configuration is best explained and understood by 32 | reading the example file above, which describes all the values in detail. 33 | 34 | ### Cloud Settings 35 | 36 | Because connecting to a cloud provider is optional, often means using a 37 | container-based setup, and differs from one provider to the next, all RAIS 38 | cloud configuration is environment-only. This means it can't be specified on 39 | the command line or in `rais.toml`. 40 | 41 | Currently RAIS can theoretically support S3, Azure, and Google Cloud backends, 42 | but only S3 has had much testing. To set up RAIS for S3, you would have to 43 | export the following environment variables (in addition to having an 44 | S3-compatible object store running): 45 | 46 | - `AWS_ACCESS_KEY_ID`: Required 47 | - `AWS_SECRET_ACCESS_KEY`: Required 48 | - `AWS_REGION`: Required 49 | - `RAIS_S3_ENDPOINT`: optionally set for custom S3 backends; e.g., "minio:9000" 50 | - `RAIS_S3_DISABLESSL`: optionally set this to "true" for custom S3 backends 51 | which don't need SSL (for instance if they're running on the same server as 52 | RAIS) 53 | - `RAIS_S3_FORCEPATHSTYLE`: optionally set this to "true" to force path-style 54 | S3 calls. This is typically necessary for custom S3 backends like minio, but 55 | not for AWS. 56 | 57 | Other backends have their own environment variables which have to be set in 58 | order to have RAIS connect to them. 59 | 60 | For a full demo of a working custom S3 backend powered by minio, see `docker/s3demo`. 61 | 62 | **Note** that external storage is going to be slower than serving images from 63 | local filesystems! Make sure you test carefully! 64 | 65 | IIIF Features 66 | ----- 67 | 68 | RAIS supports level 2 of the IIIF Image API 2.1 as well as a handful of 69 | features beyond level 2. See 70 | [the IIIF Features wiki page](https://github.com/uoregon-libraries/rais-image-server/wiki/IIIF-Features) 71 | for an in-depth look at feature support. 72 | 73 | Caching 74 | ----- 75 | 76 | RAIS can internally cache the IIIF `info.json` requests and individual tile 77 | requests. See the [RAIS Caching](https://github.com/uoregon-libraries/rais-image-server/wiki/Caching) 78 | wiki page for details. 79 | 80 | Generating tiled, multi-resolution JP2s 81 | --- 82 | 83 | RAIS performs best with JP2s which are generated as tiled, multi-resolution 84 | (think "zoom levels") images. Generating images like this is fairly easy with 85 | either the openjpeg tools or graphicsmagick. Other tools probably do this 86 | well, but we've only directly used those. 87 | 88 | You can find detailed instructions on the 89 | [How to encode jp2s](https://github.com/uoregon-libraries/rais-image-server/wiki/How-To-Encode-JP2s) 90 | wiki page. 91 | 92 | License 93 | ----- 94 | 95 | CC0 96 | 97 | RAIS Image Server is in the public domain under a 98 | [CC0](http://creativecommons.org/publicdomain/zero/1.0/) license. 99 | 100 | Contributors 101 | ----- 102 | 103 | Special thanks to Jessica Dussault (@jduss4) for providing the hand-drawn 104 | "Gocutus" logo, and Greg Tunink (@techgique) for various digital refinements to 105 | said logo. 106 | -------------------------------------------------------------------------------- /cap-level0.toml: -------------------------------------------------------------------------------- 1 | SizeByWhListed = true 2 | Default = true 3 | Jpg = true 4 | -------------------------------------------------------------------------------- /cap-max.toml: -------------------------------------------------------------------------------- 1 | # This is an example capabilities file. Please note that RAIS will not check 2 | # custom capabilities for validity; as such, if you claim something is enabled 3 | # which RAIS doesn't support, such as WEBP output, IIIF image clients may make 4 | # invalid requests RAIS won't handle. 5 | # 6 | # All values below reflect the state of RAIS's capabilities as of August, 2018. 7 | # Note that Gif output is disabled by default, but may be enabled if desired. 8 | # It is generally too slow for a production server, however. 9 | RegionByPx = true 10 | RegionByPct = true 11 | RegionSquare = true 12 | 13 | SizeByWhListed = true 14 | SizeByW = true 15 | SizeByH = true 16 | SizeByPct = true 17 | SizeByWh = true 18 | SizeByForcedWh = true 19 | SizeAboveFull = true 20 | SizeByConfinedWh = true 21 | SizeByDistortedWh = true 22 | 23 | RotationBy90s = true 24 | Mirroring = true 25 | 26 | Default = true 27 | Color = true 28 | Gray = true 29 | Bitonal = true 30 | 31 | Jpg = true 32 | Png = true 33 | Gif = false 34 | Tif = true 35 | 36 | BaseURIRedirect = true 37 | Cors = true 38 | JsonldMediaType = true 39 | -------------------------------------------------------------------------------- /compose.build.yml: -------------------------------------------------------------------------------- 1 | # This file is used to run builds on systems where installing the RAIS 2 | # dependencies is too cumbersome or simply undesirable. This is most easily 3 | # used via the `scripts/buildrun.sh` command. 4 | volumes: 5 | gopkg: {} 6 | gocache: {} 7 | 8 | services: 9 | rais-build: 10 | build: 11 | context: . 12 | dockerfile: ./docker/Dockerfile 13 | target: build 14 | volumes: 15 | - ./:/opt/rais-src:rw 16 | - ./docker/images:/var/local/images:ro 17 | - gocache:/root/.cache/go-build 18 | - gopkg:/usr/local/go/pkg 19 | command: make 20 | -------------------------------------------------------------------------------- /compose.override-example.yml: -------------------------------------------------------------------------------- 1 | # Copy this to compose.override.yml and modify as needed. This file 2 | # adds some dev-friendly container magic to the mix: 3 | # 4 | # - All binaries are mounted into the container so you can test out local changes 5 | # - RAIS directly exposes its port to the host machine for quick debugging 6 | services: 7 | rais: 8 | # Uncomment one of these if you'd like to use an image built by `make 9 | # docker` rather than testing out the latest uploaded image 10 | #image: uolibraries/rais:dev 11 | #image: uolibraries/rais:dev-alpine 12 | 13 | environment: 14 | # These next lines would allow you to pass the various S3 configuration 15 | # options through from the host's environment (or the local .env file) 16 | - RAIS_S3_ENDPOINT 17 | - RAIS_S3_DISABLESSL 18 | - RAIS_S3_FORCEPATHSTYLE 19 | - AWS_ACCESS_KEY_ID 20 | - AWS_SECRET_ACCESS_KEY 21 | - AWS_REGION 22 | - AWS_SESSION_TOKEN 23 | 24 | # If you wanted to use a configured AWS credentials file for s3, do this 25 | # and then see the volume config below 26 | - AWS_SHARED_CREDENTIALS_FILE=/etc/aws.credentials 27 | ports: 28 | - 12415:12415 29 | - 12416:12416 30 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | # This describes a self-contained demo of RAIS, using nginx to serve up the 2 | # static pages. This isn't a production configuration file!! 3 | # 4 | # If you don't set up an override file (compose.override-example.yml, 5 | # for instance), this will just use the stable version of RAIS from dockerhub. 6 | # 7 | # Put JP2s into docker/images and the apache entrypoint script will add them to 8 | # the images served by from RAIS. 9 | services: 10 | rais: 11 | image: uolibraries/rais 12 | environment: 13 | - URL 14 | - RAIS_ADDRESS 15 | - RAIS_LOGLEVEL 16 | - RAIS_TILEPATH=/var/local/images 17 | - RAIS_IIIFWEBPATH 18 | - RAIS_IIIFBASEURL 19 | - RAIS_INFOCACHELEN 20 | - RAIS_TILECACHELEN 21 | - RAIS_IMAGEMAXAREA 22 | - RAIS_IMAGEMAXWIDTH 23 | - RAIS_IMAGEMAXHEIGHT 24 | - RAIS_PLUGINS=* 25 | - RAIS_JPGQUALITY 26 | - RAIS_ALLOW_INSECURE_PLUGINS=0 27 | volumes: 28 | - ./docker/images:/var/local/images:ro 29 | - ./rais-example.toml:/etc/rais-template.toml:ro 30 | - ./cap-max.toml:/etc/rais-capabilities.toml:ro 31 | - ./docker/demo-rais-entry.sh:/entrypoint.sh:ro 32 | entrypoint: /entrypoint.sh 33 | 34 | web: 35 | image: nginx:1.15 36 | volumes: 37 | - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro 38 | - ./docker/images:/var/local/images:ro 39 | - ./docker/static:/static:ro 40 | - ./docker/demo-web-entry.sh:/entrypoint.sh:ro 41 | entrypoint: /entrypoint.sh 42 | depends_on: 43 | - rais 44 | ports: 45 | - 80:80 46 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # This generates a production image for RAIS with the S3 plugin enabled 2 | # 3 | # Examples: 4 | # 5 | # # Simple case: just build the latest production image 6 | # docker build --rm -t uolibraries/rais:latest -f ./docker/Dockerfile . 7 | # 8 | # # Generate the build image to simplify local development 9 | # docker build --rm -t uolibraries/rais:build --target build -f ./docker/Dockerfile . 10 | FROM golang:1 AS build 11 | LABEL maintainer="Jeremy Echols " 12 | 13 | # Install all the build dependencies 14 | RUN apt-get update -y && apt-get upgrade -y && \ 15 | apt-get install -y libopenjp2-7-dev libmagickcore-dev git gcc make tar findutils 16 | 17 | # Add the go mod stuff first so we aren't re-downloading dependencies except 18 | # when they actually change 19 | WORKDIR /opt/rais-src 20 | ADD ./go.mod /opt/rais-src/go.mod 21 | ADD ./go.sum /opt/rais-src/go.sum 22 | RUN go mod download 23 | 24 | # Make sure we don't just add every little thing, otherwise unimportant changes 25 | # trigger a rebuild 26 | ADD ./Makefile /opt/rais-src/Makefile 27 | ADD ./src /opt/rais-src/src 28 | ADD ./scripts /opt/rais-src/scripts 29 | ADD ./.git /opt/rais-src/.git 30 | RUN make 31 | 32 | # Manually build the ImageMagick and DataDog plugins to make this image as full 33 | # featured as possible, since debian's already pretty bloated. 34 | RUN make bin/plugins/imagick-decoder.so 35 | RUN make bin/plugins/datadog.so 36 | 37 | 38 | 39 | # Production image just installs runtime deps and copies in the binaries 40 | FROM debian:12 AS production 41 | LABEL maintainer="Jeremy Echols " 42 | 43 | # Stolen from mariadb dockerfile: add our user and group first to make sure 44 | # their IDs get assigned consistently 45 | RUN groupadd -r rais && useradd -r -g rais rais 46 | 47 | # Install the core dependencies needed for both build and production 48 | RUN apt-get update -y && apt-get upgrade -y && \ 49 | apt-get install -y libopenjp2-7 imagemagick 50 | 51 | ENV RAIS_TILEPATH=/var/local/images 52 | ENV RAIS_PLUGINS="*.so" 53 | RUN touch /etc/rais.toml && chown rais:rais /etc/rais.toml 54 | COPY --from=build /opt/rais-src/bin /opt/rais/ 55 | 56 | USER rais 57 | EXPOSE 12415 58 | ENTRYPOINT ["/opt/rais/rais-server"] 59 | -------------------------------------------------------------------------------- /docker/Dockerfile-alpine: -------------------------------------------------------------------------------- 1 | # This generates a production alpine image for RAIS 2 | # 3 | # Example: 4 | # 5 | # docker build --rm -t uolibraries/rais:latest-alpine -f ./docker/Dockerfile-alpine . 6 | FROM golang:1-alpine AS build 7 | LABEL maintainer="Jeremy Echols " 8 | 9 | # Install all the build dependencies 10 | RUN apk add --no-cache openjpeg-dev git gcc make 11 | 12 | # This is necessary for our openjp2 C bindings 13 | RUN apk add --no-cache musl-dev 14 | 15 | # This is just getting absurd, but results in a dramatically smaller rais-server 16 | RUN apk add --no-cache upx 17 | 18 | # Add the go mod stuff first so we aren't re-downloading dependencies except 19 | # when they actually change 20 | WORKDIR /opt/rais-src 21 | ADD ./go.mod /opt/rais-src/go.mod 22 | ADD ./go.sum /opt/rais-src/go.sum 23 | RUN go mod download 24 | 25 | # Make sure we don't just add every little thing, otherwise unimportant changes 26 | # trigger a rebuild 27 | ADD ./Makefile /opt/rais-src/Makefile 28 | ADD ./src /opt/rais-src/src 29 | ADD ./scripts /opt/rais-src/scripts 30 | ADD ./.git /opt/rais-src/.git 31 | RUN make rais-server 32 | 33 | RUN upx ./bin/rais-server 34 | 35 | # Production image just installs runtime deps and copies in the binaries 36 | FROM alpine:3.10 AS production 37 | LABEL maintainer="Jeremy Echols " 38 | 39 | # Add our user and group first to make sure their IDs get assigned consistently 40 | RUN addgroup -S rais && adduser -S rais -G rais 41 | 42 | # Deps 43 | RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/* 44 | RUN apk add --no-cache openjpeg 45 | 46 | ENV RAIS_TILEPATH=/var/local/images 47 | ENV RAIS_PLUGINS="-" 48 | RUN touch /etc/rais.toml && chown rais:rais /etc/rais.toml 49 | 50 | # Though we compile everything, we want our default alpine image tiny, so we offer *no* plugins 51 | COPY --from=build /opt/rais-src/bin/rais-server /opt/rais/ 52 | 53 | USER rais 54 | EXPOSE 12415 55 | ENTRYPOINT ["/opt/rais/rais-server"] 56 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # Docker 2 | 3 | This directory contains everything necessary to run RAIS under Docker, 4 | including a test image for the compose-based demo. 5 | 6 | ## Building docker images 7 | 8 | The easiest way to use these is from the parent directory's `Makefile` via 9 | `make docker`. 10 | 11 | ## Running the demo 12 | 13 | From the project root: 14 | 15 | ```bash 16 | # Set up your local server's URL if "localhost" won't suffice for any reason 17 | export URL=http://192.168.0.5 18 | 19 | # Copy images into images/ 20 | cp /some/jp2/sources/*.jp2 ./docker/images/ 21 | 22 | # Run nginx and RAIS 23 | docker compose up 24 | ``` 25 | -------------------------------------------------------------------------------- /docker/demo-rais-entry.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # demo-rais-entry.sh is the RAIS entrypoint script, which sets up the 4 | # configuration and runs the rais server 5 | 6 | # Copy the config and edit it in-place; this allows customizing most pieces of 7 | # configuration for demoing 8 | url=${URL:-} 9 | if test "$url" = ""; then 10 | echo "No URL provided; defaulting to 'http://localhost'" 11 | echo "If you can't see images, try an explicitly-set URL, e.g.:" 12 | echo 13 | echo " URL="http://192.168.0.5" docker compose up" 14 | url="http://localhost" 15 | fi 16 | 17 | exec /opt/rais/rais-server 18 | -------------------------------------------------------------------------------- /docker/demo-web-entry.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # demo-apache-entry.sh is the Apache entrypoint script, which scans for images 4 | # and writes out HTML files to include all images in the RAIS / Open Seadragon 5 | # demo 6 | 7 | # Copy the templates 8 | cp -r /static/* /usr/share/nginx/html 9 | 10 | # Insert a tile source for every file found under /var/local/images 11 | sources="" 12 | for file in $(find /var/local/images -name "*.jp2" -o -name "*.tiff" -o -name "*.jpg" -o -name "*.png"); do 13 | relpath=${file##/var/local/images/} 14 | relpath=${relpath//\//%2F} 15 | if [[ $sources != "" ]]; then 16 | sources="$sources," 17 | fi 18 | 19 | sources="$sources\"/iiif/$relpath/info.json\"" 20 | done 21 | sed "s|%SRCS%|$sources|g" /usr/share/nginx/html/template.html > /usr/share/nginx/html/iiif.html 22 | 23 | exec nginx -g "daemon off;" 24 | -------------------------------------------------------------------------------- /docker/hub.md: -------------------------------------------------------------------------------- 1 | # Supported tags and respective `Dockerfile` links 2 | 3 | - [`4`, `4.2`, `4.2.0`, `latest` (*docker/Dockerfile*)](https://github.com/uoregon-libraries/rais-image-server/blob/v4.2.0/docker/Dockerfile) 4 | - [`4-alpine`, `4.2-alpine`, `4.2.0-alpine`, `alpine` (*docker/Dockerfile-alpine*)](https://github.com/uoregon-libraries/rais-image-server/blob/v4.2.0/docker/Dockerfile-alpine) 5 | - [`3`, `3.3`, `3.3.2`, (*docker/Dockerfile*)](https://github.com/uoregon-libraries/rais-image-server/blob/v3.3.2/docker/Dockerfile) 6 | - [`3-alpine`, `3.3-alpine`, `3.3.2-alpine` (*docker/Dockerfile*)](https://github.com/uoregon-libraries/rais-image-server/blob/v3.3.2/docker/Dockerfile-alpine) 7 | 8 | # RAIS 9 | 10 | RAIS is a [IIIF](http://iiif.io/) image server, primarily built for speedy serving of tiled, multi-resolution JP2 files. RAIS is 100% open-source, and [the RAIS github repository](https://github.com/uoregon-libraries/rais-image-server) is available under a CC0 license. 11 | 12 | ## Usage 13 | 14 | See [the RAIS github wiki](https://github.com/uoregon-libraries/rais-image-server/wiki/Docker-Demo) for the easiest ways to use RAIS. 15 | -------------------------------------------------------------------------------- /docker/images/jp2tests/16-bit-gray.jp2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/images/jp2tests/16-bit-gray.jp2 -------------------------------------------------------------------------------- /docker/images/jp2tests/16-bit-rgb.jp2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/images/jp2tests/16-bit-rgb.jp2 -------------------------------------------------------------------------------- /docker/images/jp2tests/sn00063609-19091231.jp2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/images/jp2tests/sn00063609-19091231.jp2 -------------------------------------------------------------------------------- /docker/images/testfile/test-world-link.jp2: -------------------------------------------------------------------------------- 1 | test-world.jp2 -------------------------------------------------------------------------------- /docker/images/testfile/test-world.jp2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/images/testfile/test-world.jp2 -------------------------------------------------------------------------------- /docker/images/testfile/test-world.jp2-info.json: -------------------------------------------------------------------------------- 1 | {"@context":"http://iiif.io/api/image/2/context.json","@id":"%ID%","protocol":"http://iiif.io/api/image","width":800,"height":400,"tiles":[{"width":512,"scaleFactors":[1,2]},{"width":1024,"scaleFactors":[1,2]}],"profile":["http://iiif.io/api/image/2/level2.json",{"formats":["gif","tif"],"supports":["mirroring","sizeAboveFull"], "maxArea": 262144, "maxWidth": 512}]} 2 | -------------------------------------------------------------------------------- /docker/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | 5 | location /iiif/ { 6 | proxy_set_header Host $host; 7 | proxy_set_header X-Real-IP $remote_addr; 8 | proxy_set_header X-Forwarded-For $remote_addr; 9 | proxy_set_header X-Forwarded-Host $host; 10 | proxy_set_header X-Forwarded-Proto $scheme; 11 | proxy_pass http://rais:12415; 12 | } 13 | 14 | location /admin { 15 | proxy_set_header Host $host; 16 | proxy_set_header X-Real-IP $remote_addr; 17 | proxy_set_header X-Forwarded-For $remote_addr; 18 | proxy_set_header X-Forwarded-Host $host; 19 | proxy_set_header X-Forwarded-Proto $scheme; 20 | proxy_pass http://rais:12416; 21 | } 22 | 23 | location / { 24 | root /usr/share/nginx/html; 25 | index index.html index.htm; 26 | } 27 | 28 | # redirect server error pages to the static page /50x.html 29 | # 30 | error_page 500 502 503 504 /50x.html; 31 | location = /50x.html { 32 | root /usr/share/nginx/html; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /docker/s3demo/.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /docker-compose.override.yml 3 | -------------------------------------------------------------------------------- /docker/s3demo/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1 as build-env 2 | RUN apt-get update 3 | WORKDIR /s3demo 4 | ADD s3demo/go.mod /s3demo/go.mod 5 | ADD s3demo/go.sum /s3demo/go.sum 6 | RUN go mod download 7 | ADD ./s3demo/*.go /s3demo/ 8 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o server 9 | 10 | FROM alpine 11 | RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/* 12 | WORKDIR /s3demo 13 | COPY --from=build-env /s3demo/server /s3demo/server 14 | ADD ./s3demo/*.go.html /s3demo/ 15 | ADD ./static/osd /s3demo/osd 16 | EXPOSE 8080 17 | ENTRYPOINT ["/s3demo/server"] 18 | -------------------------------------------------------------------------------- /docker/s3demo/README.md: -------------------------------------------------------------------------------- 1 | s3demo for RAIS 2 | === 3 | 4 | This folder is dedicated to a mini-asset-manager-like application that presents 5 | a local file alongside any number of cloud files in an S3-compatible object 6 | store. 7 | 8 | Setup 9 | --- 10 | 11 | Run a simple, ugly "exhibit" of an entire S3 bucket! 12 | 13 | ### Get RAIS 14 | 15 | Grab the RAIS codebase and go to this directory: 16 | 17 | ```bash 18 | git clone https://github.com/uoregon-libraries/rais-image-server.git 19 | cd rais-image-server/docker/s3demo 20 | ``` 21 | 22 | ### Optional: build a docker image 23 | 24 | You may want to build your own docker image rather than using the latest stable 25 | version from dockerhub, especially if testing out plugins or other code that 26 | requires a recompile. Building an image typically means running `make docker` 27 | and/or `make docker-alpine` *from the RAIS root directory*. Note that the 28 | alpine image is *much* faster to build, but doesn't contain any plugins. 29 | 30 | Once that's done, you will want to put an override for the s3 demo so it uses 31 | your local image. Something like this can be put into 32 | `compose.override.yml` in this (the s3demo) directory: 33 | 34 | ``` 35 | version: "3.4" 36 | 37 | networks: 38 | internal: 39 | external: 40 | 41 | services: 42 | rais: 43 | image: uolibraries/rais:latest-alpine 44 | ``` 45 | 46 | ### Set up an S3 environment 47 | 48 | We can do this the easy way or the hard way.... 49 | 50 | #### The easy way: minio 51 | 52 | The demo is now set up to include "minio", an S3-compatible storage backend, by 53 | default. No actual S3 environment necessary! 54 | 55 | Run the minio container: 56 | 57 | `docker compose up minio` 58 | 59 | Create images: 60 | 61 | - Browse to `http://localhost:9000` 62 | - Log in with the acces key "key" and the secret key "secret" 63 | - Create a new bucket with the name "rais" 64 | - Upload JP2s into this bucket 65 | 66 | You can also use other s3 tools if the web interface for minio isn't to your 67 | liking - you'll just have to specify the S3 endpoint as 68 | `http://localhost:9000`. 69 | 70 | When you're done, you can stop the minio container - it'll restart in the next 71 | step anyway. 72 | 73 | #### The hard way 74 | 75 | You'll have to override the environment variables in `.env`. The easiest way 76 | is simply to copy `env-example` to `.env` and read what's there, customizing 77 | AWS-specific data as necessary. 78 | 79 | You'll also need to make sure you upload JP2 files into the bucket you 80 | designated in your `.env` file. 81 | 82 | A complete explanation of setting up and using AWS services is out of scope 83 | here, however, so if you are unfamiliar with AWS, go with the easy way above. 84 | 85 | ### Start the stack 86 | 87 | Run `docker compose up` and visit `http://localhost`. Gaze upon your glorious 88 | images, lovingly served up by RAIS. 89 | 90 | Caveats 91 | --- 92 | 93 | This is a pretty weak demo, so be advised it's really just for testing, not 94 | production use. Some caveats: 95 | 96 | - The images pulled from S3 live in ephemeral storage and will be deleted after 97 | you delete the RAIS container. This makes it simple to get at realistic 98 | first-hit costs 99 | - If you have non-images in your S3 bucket, behavior is undefined 100 | - If you're running anything else on your server at port 80, this demo won't 101 | work as-is. You may have to customize your setup (e.g., with a 102 | `compose.override.yml` file) 103 | 104 | Development 105 | --- 106 | 107 | Don't hack up the demo unless you want pain. The demo server is a mess, and 108 | the setup is a little hacky. It's here to provide a quick demo, not showcase 109 | elegant solutions to a problem. 110 | 111 | If you are a masochist, however, make sure you re-run "docker compose build" 112 | anytime you change the codebase or the go templates. 113 | -------------------------------------------------------------------------------- /docker/s3demo/admin.go.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{block "title" .}}RAIS: Administration{{end}} 5 | 6 | {{block "content" .}} 7 |

RAIS: Administration

8 | 9 |

Stats

10 |

Stats JSON

11 | 12 |

Purge Cache

13 |
14 |
15 | Type 16 | 17 |
18 | 19 | 20 |
21 | 22 |
23 | 24 | 25 |
26 | 27 |
28 | 29 | 30 |
31 |
32 | 33 | 34 |
35 | {{end}} 36 | -------------------------------------------------------------------------------- /docker/s3demo/asset.go.html: -------------------------------------------------------------------------------- 1 | {{block "title" .}}{{.Asset.Title}} - RAIS S3 Demo{{end}} 2 | 3 | {{block "content" .}} 4 |

{{.Asset.Title}}

5 |
6 | {{end}} 7 | 8 | {{block "extrajs" .}} 9 | 19 | {{end}} 20 | -------------------------------------------------------------------------------- /docker/s3demo/compose.yml: -------------------------------------------------------------------------------- 1 | networks: 2 | internal: 3 | external: 4 | 5 | volumes: 6 | minio-data: 7 | 8 | services: 9 | rais: 10 | image: uolibraries/rais:4-alpine 11 | environment: 12 | - RAIS_S3_ENDPOINT=minio:9000 13 | - RAIS_S3_DISABLESSL=true 14 | - RAIS_S3_FORCEPATHSTYLE=true 15 | - AWS_ACCESS_KEY_ID=key 16 | - AWS_SECRET_ACCESS_KEY=secretkey 17 | - AWS_SESSION_TOKEN 18 | - AWS_REGION=us-west-2 19 | volumes: 20 | - /tmp:/tmp 21 | - ../images/jp2tests/sn00063609-19091231.jp2:/var/local/images/news.jp2 22 | networks: 23 | internal: 24 | 25 | s3demo: 26 | build: 27 | context: .. 28 | dockerfile: ./s3demo/Dockerfile 29 | depends_on: 30 | - minio 31 | - rais 32 | environment: 33 | - RAIS_S3_DEMO_BUCKET=rais 34 | - RAIS_S3_ENDPOINT=minio:9000 35 | - RAIS_S3_DISABLESSL=true 36 | - RAIS_S3_FORCEPATHSTYLE=true 37 | - AWS_ACCESS_KEY_ID=key 38 | - AWS_SECRET_ACCESS_KEY=secretkey 39 | - AWS_SESSION_TOKEN 40 | - AWS_REGION=us-west-2 41 | networks: 42 | internal: 43 | 44 | web: 45 | image: nginx:1.15 46 | volumes: 47 | - ../../:/opt/rais-image-server:ro 48 | - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro 49 | depends_on: 50 | - rais 51 | - s3demo 52 | ports: 53 | - 80:80 54 | networks: 55 | internal: 56 | external: 57 | 58 | # minio for testing against a local S3-compatible API 59 | minio: 60 | image: minio/minio 61 | volumes: 62 | - minio-data:/data 63 | command: minio server /data 64 | expose: 65 | - 9000 66 | environment: 67 | - MINIO_ACCESS_KEY=key 68 | - MINIO_SECRET_KEY=secretkey 69 | ports: 70 | - 9000:9000 71 | networks: 72 | internal: 73 | external: 74 | -------------------------------------------------------------------------------- /docker/s3demo/env-example: -------------------------------------------------------------------------------- 1 | ##### REQUIRED 2 | 3 | # **NOTE**: If you use minio, you don't need to mess with this 4 | 5 | # S3 configuration - this stuff must be specific to your setup! 6 | RAIS_S3_DEMO_BUCKET=rais 7 | RAIS_S3_ENDPOINT=minio:9000 8 | RAIS_S3_DISABLESSL=true 9 | RAIS_S3_FORCEPATHSTYLE=true 10 | AWS_ACCESS_KEY_ID=key 11 | AWS_SECRET_ACCESS_KEY=secretkey 12 | AWS_SESSION_TOKEN 13 | AWS_REGION=us-west-2 14 | 15 | ##### OPTIONAL 16 | 17 | # In-memory caching is disabled here to help test timing, but can be enabled to 18 | # provide a smoother demo 19 | RAIS_TILECACHELEN=0 20 | RAIS_INFOCACHELEN=0 21 | 22 | # DEBUG logs by default because I love watching lines scroll by in my terminal 23 | RAIS_LOGLEVEL=DEBUG 24 | -------------------------------------------------------------------------------- /docker/s3demo/go.mod: -------------------------------------------------------------------------------- 1 | module s3demo 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible // indirect 7 | gocloud.dev v0.37.0 8 | golang.org/x/net v0.36.0 // indirect 9 | ) 10 | -------------------------------------------------------------------------------- /docker/s3demo/index.go.html: -------------------------------------------------------------------------------- 1 | {{block "title" .}}RAIS: S3 Images Demo{{end}} 2 | 3 | {{block "content" .}} 4 |

RAIS: S3 Images Demo

5 | {{.Bucket}} list: 6 | 7 |
8 | {{range .Assets}} 9 | 15 | {{end}} 16 |
17 | {{end}} 18 | -------------------------------------------------------------------------------- /docker/s3demo/layout.go.html: -------------------------------------------------------------------------------- 1 | {{define "layout"}} 2 | 3 | 4 | 5 | {{block "title" .}}RAIS S3 Demo{{end}} 6 | 7 | 8 | 9 | 10 | 11 | {{block "content" .}}{{end}} 12 | 13 | 14 | {{block "extrajs" .}}{{end}} 15 | 16 | {{end}} 17 | -------------------------------------------------------------------------------- /docker/s3demo/main.go: -------------------------------------------------------------------------------- 1 | // Package main, along with the various *.go.html files, demonstrates a very 2 | // simple (and ugly) asset server that reads all S3 assets in a given region 3 | // and bucket, and serves up HTML pages which point to a IIIF server (RAIS, of 4 | // course) for thumbnails and full-image views. 5 | package main 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "html/template" 11 | "io" 12 | "log" 13 | "net/url" 14 | "os" 15 | "strings" 16 | 17 | "gocloud.dev/blob" 18 | _ "gocloud.dev/blob/s3blob" 19 | ) 20 | 21 | type asset struct { 22 | Key string 23 | IIIFID string 24 | Title string 25 | } 26 | 27 | var emptyAsset asset 28 | 29 | var s3assets []asset 30 | var indexT, assetT, adminT *template.Template 31 | var s3url, zone, bucketName string 32 | var keyID, secretKey string 33 | 34 | func main() { 35 | bucketName = os.Getenv("RAIS_S3_DEMO_BUCKET") 36 | keyID = os.Getenv("AWS_ACCESS_KEY_ID") 37 | secretKey = os.Getenv("AWS_SECRET_ACCESS_KEY") 38 | 39 | if bucketName == "" || keyID == "" || secretKey == "" { 40 | fmt.Println("You must set env vars RAIS_S3_DEMO_BUCKET, AWS_ACCESS_KEY_ID, and") 41 | fmt.Println("AWS_SECRET_ACCESS_KEY before running the demo. You can export these directly") 42 | fmt.Println(`or use the compose ".env" file.`) 43 | os.Exit(1) 44 | } 45 | 46 | readAssets() 47 | preptemplates() 48 | serve() 49 | } 50 | 51 | func readAssets() { 52 | // set up the hard-coded newspaper asset first 53 | s3assets = append(s3assets, asset{Title: "Local File", Key: "news", IIIFID: "news.jp2"}) 54 | 55 | var ctx = context.Background() 56 | var bucketURL = "s3://" + bucketName + getBucketURLQuery() 57 | var bucket, err = blob.OpenBucket(ctx, bucketURL) 58 | if err != nil { 59 | log.Fatalf("Unable to open S3 bucket %q: %s", bucketURL, err) 60 | } 61 | var iter = bucket.List(nil) 62 | var obj *blob.ListObject 63 | for { 64 | obj, err = iter.Next(ctx) 65 | if err == io.EOF { 66 | break 67 | } 68 | if err != nil { 69 | log.Fatalf("Error trying to list assets: %s", err) 70 | } 71 | 72 | var key = obj.Key 73 | var id = url.PathEscape(fmt.Sprintf("s3://%s/%s", bucketName, key)) 74 | s3assets = append(s3assets, asset{Title: key, Key: key, IIIFID: id}) 75 | } 76 | 77 | log.Printf("Indexed %d assets", len(s3assets)) 78 | } 79 | 80 | // Environment variables copied from img.CloudStream 81 | const ( 82 | EnvS3Endpoint = "RAIS_S3_ENDPOINT" 83 | EnvS3DisableSSL = "RAIS_S3_DISABLESSL" 84 | EnvS3ForcePathStyle = "RAIS_S3_FORCEPATHSTYLE" 85 | ) 86 | 87 | func getBucketURLQuery() string { 88 | var endpoint = os.Getenv(EnvS3Endpoint) 89 | var disableSSL = os.Getenv(EnvS3DisableSSL) 90 | var forcePathStyle = os.Getenv(EnvS3ForcePathStyle) 91 | var query []string 92 | 93 | if endpoint != "" { 94 | query = append(query, "endpoint="+endpoint) 95 | } 96 | 97 | // Allow "t", "T", "true", "True", etc. 98 | if disableSSL != "" && strings.ToLower(disableSSL)[:1] == "t" { 99 | query = append(query, "disableSSL=true") 100 | } 101 | 102 | if forcePathStyle != "" && strings.ToLower(forcePathStyle)[:1] == "t" { 103 | query = append(query, "s3ForcePathStyle=true") 104 | } 105 | 106 | if len(query) == 0 { 107 | return "" 108 | } 109 | 110 | return "?" + strings.Join(query, "&") 111 | } 112 | 113 | func preptemplates() { 114 | var _, err = os.Stat("./layout.go.html") 115 | if err != nil { 116 | if os.IsNotExist(err) { 117 | log.Println("Unable to load HTML layout: file does not exist. Make sure you run the demo from the docker/s3demo folder.") 118 | } else { 119 | log.Printf("Error trying to open layout: %s", err) 120 | } 121 | os.Exit(1) 122 | } 123 | 124 | var root = template.New("layout") 125 | var layout = template.Must(root.ParseFiles("layout.go.html")) 126 | indexT = template.Must(template.Must(layout.Clone()).ParseFiles("index.go.html")) 127 | assetT = template.Must(template.Must(layout.Clone()).ParseFiles("asset.go.html")) 128 | adminT = template.Must(template.Must(layout.Clone()).ParseFiles("admin.go.html")) 129 | } 130 | -------------------------------------------------------------------------------- /docker/s3demo/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | 5 | location /iiif { 6 | proxy_set_header Host $host; 7 | proxy_set_header X-Real-IP $remote_addr; 8 | proxy_set_header X-Forwarded-For $remote_addr; 9 | proxy_set_header X-Forwarded-Host $host; 10 | proxy_set_header X-Forwarded-Proto $scheme; 11 | proxy_pass http://rais:12415; 12 | } 13 | 14 | location /osd { 15 | proxy_set_header Host $host; 16 | proxy_set_header X-Real-IP $remote_addr; 17 | proxy_set_header X-Forwarded-For $remote_addr; 18 | proxy_set_header X-Forwarded-Host $host; 19 | proxy_set_header X-Forwarded-Proto $scheme; 20 | root /opt/rais-image-server/docker/static; 21 | } 22 | 23 | location /admin { 24 | proxy_set_header Host $host; 25 | proxy_set_header X-Real-IP $remote_addr; 26 | proxy_set_header X-Forwarded-For $remote_addr; 27 | proxy_set_header X-Forwarded-Host $host; 28 | proxy_set_header X-Forwarded-Proto $scheme; 29 | proxy_pass http://rais:12416; 30 | } 31 | 32 | location / { 33 | proxy_set_header Host $host; 34 | proxy_set_header X-Real-IP $remote_addr; 35 | proxy_set_header X-Forwarded-For $remote_addr; 36 | proxy_set_header X-Forwarded-Host $host; 37 | proxy_set_header X-Forwarded-Proto $scheme; 38 | proxy_pass http://s3demo:8080; 39 | } 40 | 41 | # redirect server error pages to the static page /50x.html 42 | # 43 | error_page 500 502 503 504 /50x.html; 44 | location = /50x.html { 45 | root /usr/share/nginx/html; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /docker/s3demo/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | func serve() { 11 | http.HandleFunc("/", renderIndex) 12 | http.HandleFunc("/asset/", renderAsset) 13 | http.HandleFunc("/api/", renderAPIForm) 14 | 15 | var fileServer = http.FileServer(http.Dir(".")) 16 | http.Handle("/osd/", fileServer) 17 | 18 | log.Println("Listening on port 8080") 19 | var err = http.ListenAndServe(":8080", nil) 20 | if err != nil { 21 | log.Printf("Error trying to serve http: %s", err) 22 | } 23 | } 24 | 25 | type indexData struct { 26 | Bucket string 27 | Assets []asset 28 | } 29 | 30 | func renderIndex(w http.ResponseWriter, req *http.Request) { 31 | var path = req.URL.Path 32 | if path != "/" { 33 | http.NotFound(w, req) 34 | return 35 | } 36 | var data = indexData{Assets: s3assets, Bucket: bucketName} 37 | var err = indexT.Execute(w, data) 38 | if err != nil { 39 | log.Printf("Unable to serve index: %s", err) 40 | http.Error(w, "Server error", http.StatusInternalServerError) 41 | return 42 | } 43 | } 44 | 45 | func findAssetKey(req *http.Request) string { 46 | var p = req.URL.RawPath 47 | if p == "" { 48 | p = req.URL.Path 49 | } 50 | var parts = strings.Split(p, "/") 51 | if len(parts) < 3 { 52 | log.Printf("Invalid path parts %#v", parts) 53 | return "" 54 | } 55 | 56 | return strings.Join(parts[2:], "/") 57 | } 58 | 59 | func findAsset(key string) asset { 60 | for _, a2 := range s3assets { 61 | if a2.Key == key { 62 | return a2 63 | } 64 | } 65 | 66 | return emptyAsset 67 | } 68 | 69 | func renderAsset(w http.ResponseWriter, req *http.Request) { 70 | var key = findAssetKey(req) 71 | if key == "" { 72 | http.Error(w, "invalid asset request", http.StatusBadRequest) 73 | return 74 | } 75 | 76 | var a = findAsset(key) 77 | if a == emptyAsset { 78 | log.Printf("Invalid asset key %q", key) 79 | http.Error(w, fmt.Sprintf("Asset %q doesn't exist", key), http.StatusNotFound) 80 | return 81 | } 82 | 83 | var err = assetT.Execute(w, map[string]interface{}{"Asset": a}) 84 | if err != nil { 85 | log.Printf("Unable to serve asset page: %s", err) 86 | http.Error(w, "Server error", http.StatusInternalServerError) 87 | return 88 | } 89 | } 90 | 91 | func renderAPIForm(w http.ResponseWriter, req *http.Request) { 92 | var err = adminT.Execute(w, nil) 93 | if err != nil { 94 | log.Printf("Unable to serve admin page: %s", err) 95 | http.Error(w, "Server error", http.StatusInternalServerError) 96 | return 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /docker/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | OpenSeadragon Demo - RAIS Image Server 4 | 5 | 6 | Test images in docker/images 7 | 8 | 9 | -------------------------------------------------------------------------------- /docker/static/osd/images/fullpage_grouphover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/fullpage_grouphover.png -------------------------------------------------------------------------------- /docker/static/osd/images/fullpage_hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/fullpage_hover.png -------------------------------------------------------------------------------- /docker/static/osd/images/fullpage_pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/fullpage_pressed.png -------------------------------------------------------------------------------- /docker/static/osd/images/fullpage_rest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/fullpage_rest.png -------------------------------------------------------------------------------- /docker/static/osd/images/home_grouphover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/home_grouphover.png -------------------------------------------------------------------------------- /docker/static/osd/images/home_hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/home_hover.png -------------------------------------------------------------------------------- /docker/static/osd/images/home_pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/home_pressed.png -------------------------------------------------------------------------------- /docker/static/osd/images/home_rest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/home_rest.png -------------------------------------------------------------------------------- /docker/static/osd/images/next_grouphover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/next_grouphover.png -------------------------------------------------------------------------------- /docker/static/osd/images/next_hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/next_hover.png -------------------------------------------------------------------------------- /docker/static/osd/images/next_pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/next_pressed.png -------------------------------------------------------------------------------- /docker/static/osd/images/next_rest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/next_rest.png -------------------------------------------------------------------------------- /docker/static/osd/images/previous_grouphover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/previous_grouphover.png -------------------------------------------------------------------------------- /docker/static/osd/images/previous_hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/previous_hover.png -------------------------------------------------------------------------------- /docker/static/osd/images/previous_pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/previous_pressed.png -------------------------------------------------------------------------------- /docker/static/osd/images/previous_rest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/previous_rest.png -------------------------------------------------------------------------------- /docker/static/osd/images/rotateleft_grouphover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/rotateleft_grouphover.png -------------------------------------------------------------------------------- /docker/static/osd/images/rotateleft_hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/rotateleft_hover.png -------------------------------------------------------------------------------- /docker/static/osd/images/rotateleft_pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/rotateleft_pressed.png -------------------------------------------------------------------------------- /docker/static/osd/images/rotateleft_rest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/rotateleft_rest.png -------------------------------------------------------------------------------- /docker/static/osd/images/rotateright_grouphover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/rotateright_grouphover.png -------------------------------------------------------------------------------- /docker/static/osd/images/rotateright_hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/rotateright_hover.png -------------------------------------------------------------------------------- /docker/static/osd/images/rotateright_pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/rotateright_pressed.png -------------------------------------------------------------------------------- /docker/static/osd/images/rotateright_rest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/rotateright_rest.png -------------------------------------------------------------------------------- /docker/static/osd/images/zoomin_grouphover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/zoomin_grouphover.png -------------------------------------------------------------------------------- /docker/static/osd/images/zoomin_hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/zoomin_hover.png -------------------------------------------------------------------------------- /docker/static/osd/images/zoomin_pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/zoomin_pressed.png -------------------------------------------------------------------------------- /docker/static/osd/images/zoomin_rest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/zoomin_rest.png -------------------------------------------------------------------------------- /docker/static/osd/images/zoomout_grouphover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/zoomout_grouphover.png -------------------------------------------------------------------------------- /docker/static/osd/images/zoomout_hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/zoomout_hover.png -------------------------------------------------------------------------------- /docker/static/osd/images/zoomout_pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/zoomout_pressed.png -------------------------------------------------------------------------------- /docker/static/osd/images/zoomout_rest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/docker/static/osd/images/zoomout_rest.png -------------------------------------------------------------------------------- /docker/static/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | OpenSeadragon Demo - RAIS Image Server 4 | 5 | 6 |
7 | 8 | 9 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /gocutus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uoregon-libraries/rais-image-server/23e0ccb12c68078716523d00885d90fbe97b6cac/gocutus.png -------------------------------------------------------------------------------- /revive.toml: -------------------------------------------------------------------------------- 1 | ignoreGeneratedHeader = false 2 | severity = "warning" 3 | confidence = 0.8 4 | errorCode = 0 5 | warningCode = 0 6 | 7 | # Turn everything on except rules we very explicitly decide we don't want 8 | enableAllRules = true 9 | 10 | ## 11 | # Rules which probably don't benefit RAIS, or which can't be configured enough 12 | # to avoid false positives 13 | ## 14 | 15 | [rule.add-constant] 16 | Disabled = true 17 | [rule.argument-limit] 18 | Disabled = true 19 | [rule.banned-characters] 20 | Disabled = true 21 | [rule.bool-literal-in-expr] 22 | Disabled = true 23 | [rule.cognitive-complexity] 24 | Disabled = true 25 | [rule.cyclomatic] 26 | Disabled = true 27 | [rule.file-header] 28 | Disabled = true 29 | [rule.flag-parameter] 30 | Disabled = true 31 | [rule.function-length] 32 | Disabled = true 33 | [rule.function-result-limit] 34 | Disabled = true 35 | [rule.get-return] 36 | Disabled = true 37 | [rule.line-length-limit] 38 | Disabled = true 39 | [rule.max-public-structs] 40 | Disabled = true 41 | [rule.nested-structs] 42 | Disabled = true 43 | [rule.package-comments] 44 | Disabled = true 45 | [rule.unused-receiver] 46 | Disabled = true 47 | 48 | ## 49 | # Questionable omissions: these would be great for us, but either can't be 50 | # configured properly (yet) or show too many false positives to be worth 51 | # digging through the noise. If possible we should look in the future at revive 52 | # to see if it fixes the issues. 53 | # 54 | # DEVS: if you omit a rule that isn't completely unambiguously "meh", *explain 55 | # your rationale*! 56 | ## 57 | 58 | # This is just sad - it doesn't know to skip CGo exports ("//export ") when making Go functions available to C 60 | [rule.comment-spacings] 61 | Disabled = true 62 | 63 | # I could go either way on this one, but I think the pattern of making your 64 | # "private" function match your public one, other than capitalization, is 65 | # not really that bad. Maybe I'm insane. 66 | [rule.confusing-naming] 67 | Disabled = true 68 | 69 | # I actually like this one, but it gets annoying digging through reports when 70 | # there are functions built specifically to "die" on problems. panic() isn't 71 | # the right answer in a call to logger.Fatalf or a CLI arg reading function. 72 | [rule.deep-exit] 73 | Disabled = true 74 | 75 | # I love pre-compile checking of struct tags. Sadly this is busted. It doesn't 76 | # recognize valid struct tags that have had to embed backticks in them. 77 | [rule.struct-tag] 78 | Disabled = true 79 | 80 | # This hurts my soul. Super useful in some cases (meaningless returns, 81 | # unnecessary breaks, etc.), but it also bundles in single-element switch 82 | # statements ("can be replaced by an if-then"), which can be really handy (easy 83 | # to add a new case you know is going to happen, doing a very clear type 84 | # switch, etc.) and have no extra cognitive load. 85 | [rule.unnecessary-stmt] 86 | Disabled = true 87 | 88 | # This is probably a good rule, but the description is ambiguous, and it flags 89 | # things where I want a value because the type is something from C where its 90 | # zero value isn't necessarily going to be obvious to devs 91 | [rule.var-declaration] 92 | Disabled = true 93 | 94 | ## 95 | # Rules we mostly like, but need to configure 96 | ## 97 | 98 | # I don't love skipping some of these, but in most cases errors from various 99 | # print statements don't matter, while errors from others (particularly 100 | # io.Closer.Close and os.Remove) are rarely actionable or are happening as a 101 | # result of something else already having gone wrong. 102 | # 103 | # I thought about adding SaveOp to this list, but I feel like it's too easy to 104 | # accidentally rely on magicsql's error aggregation and then forget about it. 105 | # This at least hits you with a warning if you forget to explicitly skip the 106 | # error.... 107 | # 108 | # Note that this is *not* a catch-all - there are outstanding bugs (see below) 109 | # where this rule doesn't catch problems we likely have. 110 | # 111 | # Relevant issues: 112 | # - https://github.com/mgechev/revive/issues/350 113 | # - https://github.com/mgechev/revive/issues/582 114 | [rule.unhandled-error] 115 | arguments =[ 116 | "fmt\\.Print.*", "fmt\\.Fprint.*", "os\\.File\\.WriteString", "io\\.Closer\\.Close", 117 | "encoding/csv\\.Writer\\.Write", "os\\.Remove", "math/rand\\.Read", 118 | "net/http\\.ResponseWriter\\.Write", "fakehttp\\.ResponseWriter\\.Write", 119 | ] 120 | -------------------------------------------------------------------------------- /rh_config/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # RAIS This shell script takes care of starting and stopping the RAIS 4 | # image server 5 | # 6 | # chkconfig: - 80 20 7 | # description: IIIF server for JP2 images 8 | # processname: rais 9 | # pidfile: /var/run/rais.pid 10 | 11 | ### BEGIN INIT INFO 12 | # Provides: rais 13 | # Required-Start: $local_fs $remote_fs $network $named $time 14 | # Required-Stop: $local_fs $remote_fs $network $named $time 15 | # Short-Description: Start and stop RAIS 16 | # Description: rais serves various image-manipulation functionality, 17 | # primarily for presenting JP2 images in a web viewer 18 | ### END INIT INFO 19 | 20 | # Source function library. 21 | . /etc/rc.d/init.d/functions 22 | 23 | name=`basename $0` 24 | pid_file="/var/run/$name.pid" 25 | stdout_log="/var/log/$name.log" 26 | stderr_log="/var/log/$name.err" 27 | 28 | conffile="/etc/$name.toml" 29 | if [ ! -f $conffile ]; then 30 | echo "Cannot manage $name without conf file '$conffile'" 31 | exit 32 | fi 33 | 34 | prog="rais-server" 35 | exec="/opt/chronam-support/$prog" 36 | 37 | restartfile=/tmp/$prog.restart 38 | lockfile=/var/lock/subsys/$prog 39 | 40 | loop_tileserver() { 41 | # Until this file is gone, we want to restart the process 42 | touch $restartfile 43 | retry=5 44 | 45 | while [ -f $restartfile ] && [ $retry -gt 0 ]; do 46 | laststart=`date +%s` 47 | echo "Starting service: $exec" >>$stdout_log 48 | eval "$exec" >>$stdout_log 2>>$stderr_log 49 | 50 | newdate=`date +%s` 51 | let timediff=$newdate-$laststart 52 | 53 | # Log the restart to stderr and stdout logs in an apache-like format 54 | if [ -f $restartfile ]; then 55 | local logdate=`date +"[%a %b %d %H:%M:%S %Y]"` 56 | local message="Restarting server, ran for $timediff seconds before error" 57 | echo "$logdate [WARN] $message" >> $stdout_log 58 | echo "$logdate [WARN] $message" >> $stderr_log 59 | fi 60 | 61 | # Reset the retry counter as long as we don't restart too often; otherwise 62 | # we break out of the loop and assume we have a major failure 63 | let retry=$retry-1 64 | [ $timediff -gt 5 ] && retry=5 65 | done 66 | 67 | [ $retry -eq 0 ] && echo "Restart loop detected, aborting $prog" >> $stderr_log && exit 1 68 | } 69 | 70 | # Returns "true" (zero) if the passed-in app is found 71 | is_running() { 72 | ps -C $1 >/dev/null 2>/dev/null || return 1 73 | return 0 74 | } 75 | 76 | wait_for_pid() { 77 | delay=5 78 | while [ $delay -gt 0 ] && [ -z "$pid" ]; do 79 | if is_running $prog; then 80 | pid=`pidof $prog` 81 | return 0 82 | fi 83 | sleep 1 84 | let delay=$delay-1 85 | done 86 | return 1 87 | } 88 | 89 | start() { 90 | [ -x $exec ] || exit 5 91 | 92 | echo -n $"Starting $prog: " 93 | 94 | # Loop the command 95 | loop_tileserver & 96 | 97 | # Try to find the pid 98 | pid= 99 | wait_for_pid $prog 100 | 101 | if [ -z "$pid" ]; then 102 | failure && echo 103 | return 1 104 | fi 105 | 106 | echo $pid > $pid_file 107 | touch $lockfile 108 | success && echo 109 | return 0 110 | } 111 | 112 | stop() { 113 | # Don't let the loop continue when we kill the process 114 | rm -f $restartfile 115 | 116 | echo -n $"Stopping $prog: " 117 | killproc $prog 118 | retval=$? 119 | echo 120 | [ $retval -eq 0 ] && rm -f $lockfile 121 | return $retval 122 | } 123 | 124 | restart() { 125 | stop 126 | start 127 | } 128 | 129 | reload() { 130 | restart 131 | } 132 | 133 | force_reload() { 134 | restart 135 | } 136 | 137 | rh_status() { 138 | # run checks to determine if the service is running or use generic status 139 | status $prog 140 | } 141 | 142 | rh_status_q() { 143 | rh_status >/dev/null 2>&1 144 | } 145 | 146 | 147 | case "$1" in 148 | start) 149 | rh_status_q && exit 0 150 | $1 151 | ;; 152 | stop) 153 | rh_status_q || exit 0 154 | $1 155 | ;; 156 | restart) 157 | $1 158 | ;; 159 | reload) 160 | rh_status_q || exit 7 161 | $1 162 | ;; 163 | force-reload) 164 | force_reload 165 | ;; 166 | status) 167 | rh_status 168 | ;; 169 | condrestart|try-restart) 170 | rh_status_q || exit 0 171 | restart 172 | ;; 173 | *) 174 | echo $"Usage: $0 {start|stop|status|restart|condrestart|try-restart|reload|force-reload}" 175 | exit 2 176 | esac 177 | exit $? 178 | -------------------------------------------------------------------------------- /rh_config/rais.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=RAIS image server 3 | After=sysinit.target 4 | StartLimitIntervalSec=0 5 | 6 | [Service] 7 | Type=simple 8 | ExecStart=/usr/local/rais/rais-server 9 | Restart=on-failure 10 | RestartSec=5 11 | StartLimitBurst=5 12 | StartLimitIntervalSec=0 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /scripts/buildrun.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # buildrun.sh: runs the build container with any extra parameters specified on 4 | # the command line. e.g., `./buildrun.sh make test` 5 | docker compose -f compose.build.yml run --rm rais-build $@ 6 | -------------------------------------------------------------------------------- /scripts/can_cgo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $(go env CGO_ENABLED) != '1' ]]; then 4 | echo "Your system cannot build RAIS. It appears that there may not be a C compiler," 5 | echo "which is required for the openjpeg bindings. Install gcc, clang, or similar" 6 | echo "and try again." 7 | 8 | exit 1 9 | fi 10 | 11 | exit 0 12 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Compiles and deploys RAIS as a service 4 | # 5 | # This is meant as an example for going from development to production. MANY 6 | # assumptions are made: 7 | # 8 | # - You've already installed the service once 9 | # - You are using a RedHat-7-based system 10 | # - You have Go installed on your production system 11 | # - You have sudo access 12 | # - You are using this with ONI 13 | 14 | set -eu 15 | 16 | make clean 17 | make 18 | sudo systemctl stop rais 19 | sudo rm -f /usr/local/rais/rais-server 20 | sudo mkdir -p /usr/local/rais 21 | sudo cp rh_config/rais.service /usr/local/rais/rais.service 22 | 23 | if [ ! -f /etc/rais.toml ]; then 24 | sudo cp rais-example.toml /etc/rais.toml 25 | echo "New install detected - modify /etc/rais.toml as necessary" 26 | fi 27 | 28 | sudo cp bin/rais-server /usr/local/rais/rais-server 29 | sudo rm -f /usr/local/rais/plugins/* 30 | sudo cp bin/plugins/json-tracer.so /usr/local/rais/plugins/ 31 | sudo systemctl daemon-reload 32 | sudo systemctl start rais 33 | -------------------------------------------------------------------------------- /scripts/dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eu 3 | 4 | docker compose down 5 | docker compose run --rm rais-build make clean 6 | docker compose run --rm rais-build make 7 | docker compose up -d rais 8 | docker compose logs -f rais 9 | -------------------------------------------------------------------------------- /scripts/s3list.go: -------------------------------------------------------------------------------- 1 | // s3list.go is a slightly modified version of the S3 object list example in 2 | // the AWS repo... mostly because that example didn't work.... 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "os" 10 | 11 | "github.com/aws/aws-sdk-go/aws" 12 | "github.com/aws/aws-sdk-go/aws/session" 13 | "github.com/aws/aws-sdk-go/service/s3" 14 | ) 15 | 16 | func main() { 17 | if len(os.Args) != 3 { 18 | fmt.Printf("Usage: go run scripts/s3list.go \n\n") 19 | os.Exit(1) 20 | } 21 | 22 | var conf = &aws.Config{Region: &os.Args[1]} 23 | var sess, err = session.NewSession(conf) 24 | if err != nil { 25 | fmt.Println("Error trying to instantiate a new AWS session: ", err) 26 | os.Exit(1) 27 | } 28 | var svc = s3.New(sess) 29 | 30 | var out *s3.ListObjectsOutput 31 | out, err = svc.ListObjects(&s3.ListObjectsInput{Bucket: &os.Args[2]}) 32 | if err != nil { 33 | log.Println("Error trying to list objects: ", err) 34 | log.Println("Make sure you have your AWS credentials set up in ~/.aws/credentials or exported to environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY") 35 | os.Exit(1) 36 | } 37 | 38 | for _, obj := range out.Contents { 39 | fmt.Println(*obj.Key) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/cmd/jp2info/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "rais/src/jp2info" 7 | 8 | "github.com/jessevdk/go-flags" 9 | ) 10 | 11 | var opts struct { 12 | Raw bool `short:"r" long:"raw" description:"show raw JP2 info structure"` 13 | } 14 | 15 | func main() { 16 | var args []string 17 | var err error 18 | 19 | var parser = flags.NewParser(&opts, flags.Default) 20 | parser.Usage = "filename [filename...] [OPTIONS]" 21 | args, err = parser.Parse() 22 | 23 | if err != nil || len(args) < 1 { 24 | parser.WriteHelp(os.Stderr) 25 | os.Exit(1) 26 | } 27 | 28 | var arg string 29 | var s = new(jp2info.Scanner) 30 | for _, arg = range args { 31 | fmt.Printf("%s: ", arg) 32 | printScanResults(s.Scan(arg)) 33 | } 34 | } 35 | 36 | func printScanResults(i *jp2info.Info, err error) { 37 | if err != nil { 38 | // Invalid file or some variation of the spec that isn't supported 39 | fmt.Printf("Error: %s\n", err) 40 | return 41 | } 42 | 43 | if opts.Raw { 44 | fmt.Printf("%#v\n", i) 45 | } else { 46 | printInfo(i) 47 | } 48 | } 49 | 50 | func printInfo(i *jp2info.Info) { 51 | fmt.Printf("dim:%dx%d tiles:%dx%d levels:%d %d-bit %s\n", 52 | i.Width, i.Height, i.TileWidth(), i.TileHeight(), i.Levels, i.BPC, i.ColorSpace.String()) 53 | } 54 | -------------------------------------------------------------------------------- /src/cmd/rais-server/admin_handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "rais/src/iiif" 6 | ) 7 | 8 | func (s *serverStats) ServeHTTP(w http.ResponseWriter, _ *http.Request) { 9 | var json, err = s.Serialize() 10 | if err != nil { 11 | http.Error(w, "error generating json: "+err.Error(), 500) 12 | return 13 | } 14 | 15 | w.Header().Set("Content-Type", "application/json") 16 | w.Write(json) 17 | } 18 | 19 | func adminPurgeCache(w http.ResponseWriter, req *http.Request) { 20 | // All requests must be POST as hitting this endpoint can have serious consequences 21 | var reqType = req.PostFormValue("type") 22 | switch reqType { 23 | case "single": 24 | var id = iiif.ID(req.PostFormValue("id")) 25 | expireCachedImage(id) 26 | case "all": 27 | purgeCaches() 28 | default: 29 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 30 | return 31 | } 32 | 33 | w.Write([]byte("OK")) 34 | } 35 | -------------------------------------------------------------------------------- /src/cmd/rais-server/cache.go: -------------------------------------------------------------------------------- 1 | // cache.go houses all the logic for the various caching built into RAIS as 2 | // well as for sending cache invalidations to plugins 3 | 4 | package main 5 | 6 | import ( 7 | "rais/src/iiif" 8 | 9 | lru "github.com/hashicorp/golang-lru" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | var infoCache *lru.Cache 14 | var tileCache *lru.TwoQueueCache 15 | 16 | // setupCaches looks for config for caching and sets up the tile/info caches 17 | // appropriately. If they exist, we put their cache expiration functions into 18 | // the appropriate plugin lists so we can eventually transition all cache logic 19 | // to plugins. 20 | func setupCaches() { 21 | var err error 22 | icl := viper.GetInt("InfoCacheLen") 23 | if icl > 0 { 24 | infoCache, err = lru.New(icl) 25 | if err != nil { 26 | Logger.Fatalf("Unable to start info cache: %s", err) 27 | } 28 | stats.InfoCache.Enabled = true 29 | purgeCachePlugins = append(purgeCachePlugins, infoCache.Purge) 30 | expireCachedImagePlugins = append(expireCachedImagePlugins, func(id iiif.ID) { infoCache.Remove(id) }) 31 | } 32 | 33 | tcl := viper.GetInt("TileCacheLen") 34 | if tcl > 0 { 35 | Logger.Debugf("Creating a tile cache to hold up to %d tiles", tcl) 36 | tileCache, err = lru.New2Q(tcl) 37 | if err != nil { 38 | Logger.Fatalf("Unable to start info cache: %s", err) 39 | } 40 | stats.TileCache.Enabled = true 41 | purgeCachePlugins = append(purgeCachePlugins, tileCache.Purge) 42 | // Unfortunately, the tile cache is keyed by the entire IIIF request, not the 43 | // ID (obviously). Since we can't get a list of all cached tiles for a given 44 | // image, we have to purge the whole cache. 45 | expireCachedImagePlugins = append(expireCachedImagePlugins, func(_ iiif.ID) { tileCache.Purge() }) 46 | } 47 | } 48 | 49 | // purgeCaches removes all cached data 50 | func purgeCaches() { 51 | for _, plug := range purgeCachePlugins { 52 | plug() 53 | } 54 | } 55 | 56 | // expireCachedImage removes cached data for a single IIIF ID 57 | func expireCachedImage(id iiif.ID) { 58 | for _, plug := range expireCachedImagePlugins { 59 | plug(id) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/cmd/rais-server/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "net/url" 7 | "os" 8 | 9 | "github.com/spf13/pflag" 10 | "github.com/spf13/viper" 11 | "github.com/uoregon-libraries/gopkg/logger" 12 | ) 13 | 14 | // parseConf centralizes all config reading and validating for the core RAIS options 15 | func parseConf() { 16 | // Default configuration values 17 | var defaultAddress = ":12415" 18 | var defaultAdminAddress = ":12416" 19 | var defaultInfoCacheLen = 10000 20 | var defaultLogLevel = logger.Debug.String() 21 | var defaultPlugins = "-" 22 | var defaultJPGQuality = 75 23 | 24 | // Defaults 25 | viper.SetDefault("Address", defaultAddress) 26 | viper.SetDefault("AdminAddress", defaultAdminAddress) 27 | viper.SetDefault("InfoCacheLen", defaultInfoCacheLen) 28 | viper.SetDefault("LogLevel", defaultLogLevel) 29 | viper.SetDefault("Plugins", defaultPlugins) 30 | viper.SetDefault("JPGQuality", defaultJPGQuality) 31 | 32 | // Allow all configuration to be in environment variables 33 | viper.SetEnvPrefix("RAIS") 34 | viper.AutomaticEnv() 35 | 36 | // Config file options 37 | viper.SetConfigName("rais") 38 | viper.AddConfigPath("/etc") 39 | viper.AddConfigPath(".") 40 | if err := viper.ReadInConfig(); err != nil { 41 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 42 | fmt.Printf("ERROR: Invalid RAIS config file (/etc/rais.toml or ./rais.toml): %s\n", err) 43 | os.Exit(1) 44 | } 45 | } 46 | 47 | // CLI flags 48 | pflag.String("iiif-base-url", "", "Base URL for RAIS to report in info.json requests "+ 49 | "(defaults to the requests as they come in, so you probably don't want to set this)") 50 | viper.BindPFlag("IIIFBaseURL", pflag.CommandLine.Lookup("iiif-base-url")) 51 | pflag.String("iiif-web-path", "/iiif", `Base path for serving IIIF requests, e.g., "/iiif"`) 52 | viper.BindPFlag("IIIFWebPath", pflag.CommandLine.Lookup("iiif-web-path")) 53 | pflag.String("address", defaultAddress, "http service address") 54 | viper.BindPFlag("Address", pflag.CommandLine.Lookup("address")) 55 | pflag.String("admin-address", defaultAdminAddress, "http service for administrative endpoints") 56 | viper.BindPFlag("AdminAddress", pflag.CommandLine.Lookup("admin-address")) 57 | pflag.String("tile-path", "", "Base path for images") 58 | viper.BindPFlag("TilePath", pflag.CommandLine.Lookup("tile-path")) 59 | pflag.Int("iiif-info-cache-size", defaultInfoCacheLen, "Maximum cached image info entries (IIIF only)") 60 | viper.BindPFlag("InfoCacheLen", pflag.CommandLine.Lookup("iiif-info-cache-size")) 61 | pflag.String("capabilities-file", "", "TOML file describing capabilities, rather than everything RAIS supports") 62 | viper.BindPFlag("CapabilitiesFile", pflag.CommandLine.Lookup("capabilities-file")) 63 | pflag.String("log-level", defaultLogLevel, "Log level: the server will only log notifications at "+ 64 | "this level and above (must be DEBUG, INFO, WARN, ERROR, or CRIT)") 65 | viper.BindPFlag("LogLevel", pflag.CommandLine.Lookup("log-level")) 66 | pflag.Int64("image-max-area", math.MaxInt64, "Maximum area (w x h) of images to be served") 67 | viper.BindPFlag("ImageMaxArea", pflag.CommandLine.Lookup("image-max-area")) 68 | pflag.Int("image-max-width", math.MaxInt32, "Maximum width of images to be served") 69 | viper.BindPFlag("ImageMaxWidth", pflag.CommandLine.Lookup("image-max-width")) 70 | pflag.Int("image-max-height", math.MaxInt32, "Maximum height of images to be served") 71 | viper.BindPFlag("ImageMaxHeight", pflag.CommandLine.Lookup("image-max-height")) 72 | pflag.String("plugins", defaultPlugins, "comma-separated plugin pattern list, e.g., "+ 73 | `"json-tracer.so,/opt/rais/plugins/*.so"`) 74 | viper.BindPFlag("Plugins", pflag.CommandLine.Lookup("plugins")) 75 | pflag.Int("jpg-quality", 75, "Quality of JPEG output") 76 | viper.BindPFlag("JPGQuality", pflag.CommandLine.Lookup("jpg-quality")) 77 | pflag.String("scheme-map", "", "Whitespace-delimited map of scheme to prefix, e.g., "+ 78 | `"acme=s3://bucket1 marc=s3://bucket2/some/path"`) 79 | viper.BindPFlag("SchemeMap", pflag.CommandLine.Lookup("scheme-map")) 80 | 81 | pflag.Parse() 82 | 83 | // Make sure required values exist 84 | if viper.GetString("TilePath") == "" { 85 | fmt.Println("ERROR: tile path is required") 86 | pflag.Usage() 87 | os.Exit(1) 88 | } 89 | 90 | var level = logger.LogLevelFromString(viper.GetString("LogLevel")) 91 | if level == logger.Invalid { 92 | fmt.Println("ERROR: Invalid log level (must be DEBUG, INFO, WARN, ERROR, or CRIT)") 93 | pflag.Usage() 94 | os.Exit(1) 95 | } 96 | 97 | var baseIIIFURL = viper.GetString("IIIFBaseURL") 98 | if baseIIIFURL != "" { 99 | var u, err = url.Parse(baseIIIFURL) 100 | if err == nil && u.Scheme == "" { 101 | err = fmt.Errorf("empty scheme") 102 | } 103 | if err == nil && u.Host == "" { 104 | err = fmt.Errorf("empty host") 105 | } 106 | if err == nil && u.Path != "" { 107 | err = fmt.Errorf("only scheme and hostname may be specified") 108 | } 109 | if err != nil { 110 | fmt.Printf("ERROR: invalid Base IIIF URL (%s) specified: %s\n", baseIIIFURL, err) 111 | pflag.Usage() 112 | os.Exit(1) 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/cmd/rais-server/encode.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "image" 6 | "image/gif" 7 | "image/jpeg" 8 | "image/png" 9 | "io" 10 | "rais/src/iiif" 11 | 12 | "github.com/spf13/viper" 13 | "golang.org/x/image/tiff" 14 | ) 15 | 16 | // ErrInvalidEncodeFormat is the error returned when encoding fails due to a 17 | // file format RAIS doesn't support 18 | var ErrInvalidEncodeFormat = errors.New("Unable to encode: unsupported format") 19 | 20 | // EncodeImage uses the built-in image libs to write an image to the browser 21 | func EncodeImage(w io.Writer, img image.Image, format iiif.Format) error { 22 | switch format { 23 | case iiif.FmtJPG: 24 | return jpeg.Encode(w, img, &jpeg.Options{Quality: viper.GetInt("JPGQuality")}) 25 | case iiif.FmtPNG: 26 | return png.Encode(w, img) 27 | case iiif.FmtGIF: 28 | return gif.Encode(w, img, &gif.Options{NumColors: 256}) 29 | case iiif.FmtTIF: 30 | return tiff.Encode(w, img, &tiff.Options{Compression: tiff.Deflate, Predictor: true}) 31 | } 32 | 33 | return ErrInvalidEncodeFormat 34 | } 35 | -------------------------------------------------------------------------------- /src/cmd/rais-server/errors.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // HandlerError represents an HTTP error message and status code 4 | type HandlerError struct { 5 | Message string 6 | Code int 7 | } 8 | 9 | // NewError generates a new HandlerError with the given message and code 10 | func NewError(m string, c int) *HandlerError { 11 | return &HandlerError{m, c} 12 | } 13 | -------------------------------------------------------------------------------- /src/cmd/rais-server/headers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "rais/src/img" 6 | "time" 7 | ) 8 | 9 | func sendHeaders(w http.ResponseWriter, req *http.Request, res *img.Resource) error { 10 | // Set headers 11 | w.Header().Set("Last-Modified", res.Streamer().ModTime().Format(time.RFC1123)) 12 | w.Header().Set("Access-Control-Allow-Origin", "*") 13 | 14 | // Check for forced download parameter 15 | query := req.URL.Query() 16 | if query["download"] != nil { 17 | w.Header().Set("Content-Disposition", "attachment") 18 | } 19 | 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /src/cmd/rais-server/image_info.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // ImageInfo holds just enough data to reproduce the dynamic portions of 4 | // info.json 5 | type ImageInfo struct { 6 | Width, Height int 7 | TileWidth, TileHeight int 8 | Levels int 9 | } 10 | -------------------------------------------------------------------------------- /src/cmd/rais-server/internal/servers/servers.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "sync" 7 | "time" 8 | 9 | "github.com/gorilla/mux" 10 | "github.com/uoregon-libraries/gopkg/logger" 11 | ) 12 | 13 | var servers = make(map[string]*Server) 14 | var running sync.WaitGroup 15 | 16 | // Server wraps an http.Server with some helpers for running in the background, 17 | // setting up sane defaults (no global ServeMux), and shutdown of all 18 | // registered servers 19 | type Server struct { 20 | *http.Server 21 | Name string 22 | Mux *mux.Router 23 | middleware []func(http.Handler) http.Handler 24 | } 25 | 26 | // New registers a named server at the given bind address. If the address is 27 | // already in use, the "new" server will instead merge with the existing 28 | // server. 29 | func New(name, addr string) *Server { 30 | if servers[addr] != nil { 31 | servers[addr].Name += ", " + name 32 | return servers[addr] 33 | } 34 | 35 | var m = mux.NewRouter() 36 | m.SkipClean(true) 37 | var s = &Server{ 38 | Name: name, 39 | Mux: m, 40 | Server: &http.Server{ 41 | ReadTimeout: 5 * time.Second, 42 | WriteTimeout: 30 * time.Second, 43 | Addr: addr, 44 | Handler: m, 45 | }, 46 | } 47 | 48 | servers[addr] = s 49 | return s 50 | } 51 | 52 | // AddMiddleware appends to the list of middleware handlers - these wrap *all* 53 | // handlers in the given middleware 54 | // 55 | // Middleware is any function that takes a handler and returns another handler 56 | func (s *Server) AddMiddleware(mw func(http.Handler) http.Handler) { 57 | s.middleware = append(s.middleware, mw) 58 | } 59 | 60 | func (s *Server) wrapMiddleware(handler http.Handler) http.Handler { 61 | for _, m := range s.middleware { 62 | handler = m(handler) 63 | } 64 | 65 | return handler 66 | } 67 | 68 | // HandleExact sets up a gorilla/mux handler that response only to the exact 69 | // path given 70 | func (s *Server) HandleExact(pth string, handler http.Handler) { 71 | s.Mux.Path(pth).Handler(s.wrapMiddleware(handler)) 72 | } 73 | 74 | // HandlePrefix sets up a gorilla/mux handler for any request where the 75 | // beginning of the path matches the given prefix 76 | func (s *Server) HandlePrefix(prefix string, handler http.Handler) { 77 | s.Mux.PathPrefix(prefix).Handler(s.wrapMiddleware(handler)) 78 | } 79 | 80 | // run wraps http.Server's ListenAndServe in a background-friendly way, sending 81 | // any errors to the "done" callback when the server closes 82 | func (s *Server) run(done func(*Server, error)) { 83 | var err = s.Server.ListenAndServe() 84 | if err == http.ErrServerClosed { 85 | err = nil 86 | } 87 | done(s, err) 88 | } 89 | 90 | // Shutdown stops all registered servers 91 | func Shutdown(ctx context.Context, l *logger.Logger) { 92 | for _, s := range servers { 93 | var err = s.Shutdown(ctx) 94 | if err != nil { 95 | l.Errorf("Error shutting down server %q: %s", s.Name, err) 96 | } 97 | } 98 | } 99 | 100 | // ListenAndServe runs all servers and waits for them to shut down, running onErr 101 | // when a server returns an error (other than http.ErrServerClosed) occurs 102 | func ListenAndServe(onErr func(*Server, error)) { 103 | var done = func(s *Server, err error) { 104 | running.Done() 105 | if err != nil { 106 | onErr(s, err) 107 | } 108 | } 109 | 110 | for _, s := range servers { 111 | running.Add(1) 112 | go s.run(done) 113 | } 114 | 115 | running.Wait() 116 | } 117 | -------------------------------------------------------------------------------- /src/cmd/rais-server/internal/statusrecorder/statusrecorder.go: -------------------------------------------------------------------------------- 1 | package statusrecorder 2 | 3 | import "net/http" 4 | 5 | // StatusRecorder wraps an http.ResponseWriter. It intercepts WriteHeader 6 | // calls so we can record the status code for logging purposes. 7 | type StatusRecorder struct { 8 | http.ResponseWriter 9 | Status int 10 | } 11 | 12 | // New initializes the fake writer to a status of 200 - if a status isn't 13 | // explicitly written, the http library will default to 200 but we won't have 14 | // captured it if we don't also default it 15 | func New(w http.ResponseWriter) *StatusRecorder { 16 | return &StatusRecorder{w, http.StatusOK} 17 | } 18 | 19 | // WriteHeader stores and then passes the code down to the real writer 20 | func (rec *StatusRecorder) WriteHeader(code int) { 21 | rec.Status = code 22 | rec.ResponseWriter.WriteHeader(code) 23 | } 24 | -------------------------------------------------------------------------------- /src/cmd/rais-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "path" 9 | "rais/src/cmd/rais-server/internal/servers" 10 | "rais/src/iiif" 11 | "rais/src/img" 12 | "rais/src/openjpeg" 13 | "rais/src/plugins" 14 | "rais/src/version" 15 | "strings" 16 | "sync" 17 | "time" 18 | 19 | "github.com/BurntSushi/toml" 20 | "github.com/spf13/viper" 21 | "github.com/uoregon-libraries/gopkg/interrupts" 22 | "github.com/uoregon-libraries/gopkg/logger" 23 | ) 24 | 25 | // Logger is the server's central logger.Logger instance 26 | var Logger *logger.Logger 27 | 28 | // Global server stats for admin information gathering 29 | var stats = new(serverStats) 30 | 31 | // wait ensures main() doesn't exit until the server(s) are all shutdown 32 | var wait sync.WaitGroup 33 | 34 | func main() { 35 | parseConf() 36 | Logger = logger.New(logger.LogLevelFromString(viper.GetString("LogLevel"))) 37 | openjpeg.Logger = Logger 38 | 39 | setupCaches() 40 | 41 | var pluginList string 42 | 43 | // Don't let the default plugin list be used if we have an explicit value of "" 44 | if viper.IsSet("Plugins") { 45 | pluginList = viper.GetString("Plugins") 46 | } 47 | 48 | if pluginList == "" || pluginList == "-" { 49 | Logger.Infof("No plugins will attempt to be loaded") 50 | } else { 51 | LoadPlugins(Logger, strings.Split(pluginList, ",")) 52 | } 53 | 54 | // Register our JP2 decoder after plugins have been loaded to allow plugins 55 | // to handle images - for instance, we might want a pyramidal tiff plugin or 56 | // something one day 57 | img.RegisterDecodeHandler(decodeJP2) 58 | 59 | // File streamer for handling images on the local filesystem 60 | img.RegisterStreamReader(fileStreamReader) 61 | 62 | // Cloud streamer for attempting to handle anything else. Technically this 63 | // can do local files, too, but the overhead is just too much if we want to 64 | // keep showcasing how fast RAIS is with local files.... 65 | img.RegisterStreamReader(cloudStreamReader) 66 | 67 | tilePath := viper.GetString("TilePath") 68 | webPath := viper.GetString("IIIFWebPath") 69 | if webPath == "" { 70 | webPath = "/iiif" 71 | } 72 | p2 := path.Clean(webPath) 73 | if webPath != p2 { 74 | Logger.Warnf("WebPath %q cleaned; using %q instead", webPath, p2) 75 | webPath = p2 76 | } 77 | address := viper.GetString("Address") 78 | adminAddress := viper.GetString("AdminAddress") 79 | 80 | Logger.Debugf("Serving images from %q", tilePath) 81 | ih := NewImageHandler(tilePath, webPath) 82 | ih.Maximums.Area = viper.GetInt64("ImageMaxArea") 83 | ih.Maximums.Width = viper.GetInt("ImageMaxWidth") 84 | ih.Maximums.Height = viper.GetInt("ImageMaxHeight") 85 | 86 | // Check for scheme remapping configuration - if it exists, it's the final id-to-URL handler 87 | schemeMapConfig := viper.GetString("SchemeMap") 88 | if schemeMapConfig != "" { 89 | err := parseSchemeMap(ih, schemeMapConfig) 90 | if err != nil { 91 | Logger.Fatalf("Error parsing SchemeMap: %s", err) 92 | } 93 | } 94 | 95 | iiifBaseURL := viper.GetString("IIIFBaseURL") 96 | if iiifBaseURL != "" { 97 | baseURL, _ := url.Parse(iiifBaseURL) 98 | Logger.Infof("Explicitly setting IIIF base URL to %q", baseURL) 99 | ih.BaseURL = baseURL 100 | } 101 | 102 | capfile := viper.GetString("CapabilitiesFile") 103 | if capfile != "" { 104 | ih.FeatureSet = &iiif.FeatureSet{} 105 | _, err := toml.DecodeFile(capfile, &ih.FeatureSet) 106 | if err != nil { 107 | Logger.Fatalf("Invalid file or formatting in capabilities file '%s'", capfile) 108 | } 109 | Logger.Debugf("Setting IIIF capabilities from file '%s'", capfile) 110 | } 111 | 112 | // Setup server info in our stats structure 113 | stats.ServerStart = time.Now() 114 | stats.RAISVersion = version.Version 115 | 116 | // Set up handlers / listeners 117 | var pubSrv = servers.New("RAIS", address) 118 | pubSrv.AddMiddleware(logMiddleware) 119 | handle(pubSrv, ih.WebPathPrefix+"/", http.HandlerFunc(ih.IIIFRoute)) 120 | handle(pubSrv, "/", http.NotFoundHandler()) 121 | 122 | var admSrv = servers.New("RAIS Admin", adminAddress) 123 | admSrv.AddMiddleware(logMiddleware) 124 | admSrv.HandleExact("/admin/stats.json", stats) 125 | admSrv.HandlePrefix("/admin/cache/purge", http.HandlerFunc(adminPurgeCache)) 126 | 127 | interrupts.TrapIntTerm(shutdown) 128 | 129 | Logger.Infof("RAIS %s starting...", version.Version) 130 | servers.ListenAndServe(func(srv *servers.Server, err error) { 131 | Logger.Errorf("Error running %q server: %s", srv.Name, err) 132 | shutdown() 133 | }) 134 | wait.Wait() 135 | } 136 | 137 | // handle sends the pattern and raw handler to plugins, and sets up routing on 138 | // whatever is returned (if anything). All plugins which wrap handlers are 139 | // allowed to run, but the behavior could definitely get weird depending on 140 | // what a given plugin does. Ye be warned. 141 | func handle(srv *servers.Server, pattern string, handler http.Handler) { 142 | for _, plug := range wrapHandlerPlugins { 143 | var h2, err = plug(pattern, handler) 144 | if err == nil { 145 | handler = h2 146 | } else if err != plugins.ErrSkipped { 147 | Logger.Fatalf("Error trying to wrap handler %q: %s", pattern, err) 148 | } 149 | } 150 | 151 | srv.HandlePrefix(pattern, handler) 152 | } 153 | 154 | func shutdown() { 155 | wait.Add(1) 156 | Logger.Infof("Stopping RAIS...") 157 | servers.Shutdown(context.Background(), Logger) 158 | 159 | if len(teardownPlugins) > 0 { 160 | Logger.Infof("Tearing down plugins") 161 | for _, plug := range teardownPlugins { 162 | plug() 163 | } 164 | Logger.Infof("Plugin teardown complete") 165 | } 166 | 167 | Logger.Infof("RAIS Stopped") 168 | wait.Done() 169 | } 170 | 171 | func parseSchemeMap(ih *ImageHandler, schemeMapConfig string) error { 172 | var confs = strings.Fields(schemeMapConfig) 173 | for _, conf := range confs { 174 | var parts = strings.Split(conf, "=") 175 | if len(parts) != 2 { 176 | return fmt.Errorf(`invalid scheme map %q: format must be "scheme=prefix"`, conf) 177 | } 178 | var scheme, prefix = parts[0], parts[1] 179 | 180 | var err = ih.AddSchemeMap(scheme, prefix) 181 | if err != nil { 182 | return fmt.Errorf("invalid scheme map %q: %s", conf, err) 183 | } 184 | } 185 | 186 | return nil 187 | } 188 | -------------------------------------------------------------------------------- /src/cmd/rais-server/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | func TestParseSchemeMap(t *testing.T) { 10 | var tests = map[string]struct { 11 | input string 12 | hasError bool 13 | extraMaps map[string]string 14 | }{ 15 | "simple": { 16 | input: "foo=file:///var/local bar=s3://bucket/path/", hasError: false, 17 | extraMaps: map[string]string{"foo": "file:///var/local/", "bar": "s3://bucket/path/"}, 18 | }, 19 | "double map": { 20 | input: "foo=file:///var/local bar=s3://bucket/path/ foo=file:///etc", hasError: true, 21 | extraMaps: map[string]string{"foo": "file:///var/local/", "bar": "s3://bucket/path/"}, 22 | }, 23 | "remap internals": {input: "file=file:///", hasError: true, extraMaps: nil}, 24 | "invalid prefix scheme": {input: "file2=/var/local", hasError: true, extraMaps: nil}, 25 | "file with a host": {input: "file2=file://host/path", hasError: true, extraMaps: nil}, 26 | "s3 with no host": {input: "coll1=s3:///path", hasError: true, extraMaps: nil}, 27 | } 28 | 29 | for name, tc := range tests { 30 | t.Run(name, func(t *testing.T) { 31 | var actual = NewImageHandler("/tilepath", "/iiif") 32 | var err = parseSchemeMap(actual, tc.input) 33 | if err == nil && tc.hasError { 34 | t.Errorf("expected error, got nil") 35 | } 36 | if err != nil && !tc.hasError { 37 | t.Errorf("expected no error, got %s", err) 38 | } 39 | 40 | var expected = NewImageHandler("/tilepath", "url") 41 | for scheme, prefix := range tc.extraMaps { 42 | expected.schemeMap[scheme] = prefix 43 | } 44 | var diff = cmp.Diff(expected.schemeMap, actual.schemeMap) 45 | if diff != "" { 46 | t.Errorf(diff) 47 | } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/cmd/rais-server/middleware.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "rais/src/cmd/rais-server/internal/statusrecorder" 6 | ) 7 | 8 | func logMiddleware(next http.Handler) http.Handler { 9 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 10 | var ip = r.RemoteAddr 11 | var forwarded = r.Header.Get("X-Forwarded-For") 12 | if forwarded != "" { 13 | ip = ip + "," + forwarded 14 | } 15 | var sr = statusrecorder.New(w) 16 | next.ServeHTTP(sr, r) 17 | Logger.Infof("Request: [%s] %s - %d", ip, r.URL, sr.Status) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/cmd/rais-server/plugins.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | "plugin" 10 | "rais/src/iiif" 11 | "reflect" 12 | "sort" 13 | "strings" 14 | 15 | "github.com/uoregon-libraries/gopkg/logger" 16 | ) 17 | 18 | var wrapHandlerPlugins []func(string, http.Handler) (http.Handler, error) 19 | var teardownPlugins []func() 20 | var purgeCachePlugins []func() 21 | var expireCachedImagePlugins []func(iiif.ID) 22 | 23 | // pluginsFor returns a list of all plugin files which matched the given 24 | // pattern. Files are sorted by name. 25 | func pluginsFor(pattern string) ([]string, error) { 26 | if !filepath.IsAbs(pattern) { 27 | var dir = filepath.Join(filepath.Dir(os.Args[0]), "plugins") 28 | pattern = filepath.Join(dir, pattern) 29 | } 30 | 31 | var files, err = filepath.Glob(pattern) 32 | if err != nil { 33 | return nil, fmt.Errorf("invalid plugin file pattern %q", pattern) 34 | } 35 | if len(files) == 0 { 36 | return nil, fmt.Errorf("plugin pattern %q doesn't match any files", pattern) 37 | } 38 | 39 | sort.Strings(files) 40 | return files, nil 41 | } 42 | 43 | // LoadPlugins searches for any plugins matching the pattern given. If the 44 | // pattern is not an absolute URL, it is treated as a pattern under the 45 | // binary's dir/plugins. 46 | func LoadPlugins(l *logger.Logger, patterns []string) { 47 | var plugFiles []string 48 | var seen = make(map[string]bool) 49 | for _, pattern := range patterns { 50 | var matches, err = pluginsFor(pattern) 51 | if err != nil { 52 | l.Warnf("Skipping invalid plugin pattern %q: %s", pattern, err) 53 | } 54 | 55 | // We do a sanity check before actually processing any plugins 56 | for _, file := range matches { 57 | if filepath.Ext(file) != ".so" { 58 | l.Fatalf("Cannot load unknown file %q (plugins must be compiled .so files)", file) 59 | } 60 | if seen[file] { 61 | l.Fatalf("Cannot load the same plugin twice (%q)", file) 62 | } 63 | seen[file] = true 64 | } 65 | 66 | plugFiles = append(plugFiles, matches...) 67 | } 68 | 69 | for _, file := range plugFiles { 70 | l.Infof("Loading plugin %q", file) 71 | var err = loadPlugin(file, l) 72 | if err != nil { 73 | l.Errorf("Unable to load %q: %s", file, err) 74 | } 75 | } 76 | } 77 | 78 | type pluginWrapper struct { 79 | *plugin.Plugin 80 | path string 81 | functions []string 82 | errors []string 83 | } 84 | 85 | func newPluginWrapper(path string) (*pluginWrapper, error) { 86 | var p, err = plugin.Open(path) 87 | if err != nil { 88 | return nil, fmt.Errorf("cannot load plugin %q: %s", path, err) 89 | } 90 | return &pluginWrapper{Plugin: p, path: path}, nil 91 | } 92 | 93 | // loadPluginFn loads the symbol by the given name and attempts to set it to 94 | // the given object via reflection. If the two aren't the same type, an error 95 | // is added to the pluginWrapper's error list. 96 | func (pw *pluginWrapper) loadPluginFn(name string, obj any) { 97 | var sym, err = pw.Lookup(name) 98 | if err != nil { 99 | return 100 | } 101 | 102 | var objElem = reflect.ValueOf(obj).Elem() 103 | var objType = objElem.Type() 104 | var symV = reflect.ValueOf(sym) 105 | 106 | if !symV.Type().AssignableTo(objType) { 107 | pw.errors = append(pw.errors, fmt.Sprintf("invalid signature for %s (expecting %s)", name, objType)) 108 | return 109 | } 110 | 111 | objElem.Set(symV) 112 | pw.functions = append(pw.functions, name) 113 | } 114 | 115 | // loadPlugin attempts to read the given plugin file and extract known symbols. 116 | // If a plugin exposes Initialize or SetLogger, they're called here once we're 117 | // sure the plugin is valid. All other functions are indexed globally for use 118 | // in the RAIS image serving handler. 119 | func loadPlugin(fullpath string, l *logger.Logger) error { 120 | var pw, err = newPluginWrapper(fullpath) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | // Set up dummy / no-op functions so we can call these without risk 126 | var log = func(*logger.Logger) {} 127 | var initialize = func() {} 128 | 129 | // Simply initialize those functions we only want indexed if they exist 130 | var teardown func() 131 | var wrapHandler func(string, http.Handler) (http.Handler, error) 132 | var prgCache func() 133 | var expCachedImg func(iiif.ID) 134 | 135 | pw.loadPluginFn("SetLogger", &log) 136 | pw.loadPluginFn("Initialize", &initialize) 137 | pw.loadPluginFn("Teardown", &teardown) 138 | pw.loadPluginFn("WrapHandler", &wrapHandler) 139 | pw.loadPluginFn("PurgeCaches", &prgCache) 140 | pw.loadPluginFn("ExpireCachedImage", &expCachedImg) 141 | 142 | if len(pw.errors) != 0 { 143 | return errors.New(strings.Join(pw.errors, ", ")) 144 | } 145 | if len(pw.functions) == 0 { 146 | return fmt.Errorf("no known functions exposed") 147 | } 148 | 149 | // We need to call SetLogger and Initialize immediately, as they're never 150 | // called a second time and they tell us if the plugin is going to be used 151 | log(l) 152 | initialize() 153 | 154 | // After initialization, we check if the plugin explicitly set itself to Disabled 155 | var sym plugin.Symbol 156 | sym, err = pw.Lookup("Disabled") 157 | if err == nil { 158 | var disabled, ok = sym.(*bool) 159 | if !ok { 160 | return fmt.Errorf("non-boolean Disabled value exposed") 161 | } 162 | if *disabled { 163 | l.Infof("%q is disabled", fullpath) 164 | return nil 165 | } 166 | l.Debugf("%q is explicitly enabled", fullpath) 167 | } 168 | 169 | // Index remaining functions 170 | if teardown != nil { 171 | teardownPlugins = append(teardownPlugins, teardown) 172 | } 173 | if wrapHandler != nil { 174 | wrapHandlerPlugins = append(wrapHandlerPlugins, wrapHandler) 175 | } 176 | if prgCache != nil { 177 | purgeCachePlugins = append(purgeCachePlugins, prgCache) 178 | } 179 | if expCachedImg != nil { 180 | expireCachedImagePlugins = append(expireCachedImagePlugins, expCachedImg) 181 | } 182 | 183 | // Add info to stats 184 | stats.Plugins = append(stats.Plugins, plugStats{ 185 | Path: fullpath, 186 | Functions: pw.functions, 187 | }) 188 | 189 | return nil 190 | } 191 | -------------------------------------------------------------------------------- /src/cmd/rais-server/register.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/url" 5 | "rais/src/img" 6 | "rais/src/openjpeg" 7 | "rais/src/plugins" 8 | ) 9 | 10 | // decodeJP2 is the last decoder function we try, after any plugins have been 11 | // tried, so we don't actually care about the URL - we just try it and see what 12 | // happens. 13 | func decodeJP2(s img.Streamer) (img.DecodeFunc, error) { 14 | return func() (img.Decoder, error) { return openjpeg.NewJP2Image(s) }, nil 15 | } 16 | 17 | // fileStreamReader is the last, and default, streamer for RAIS to try... it's 18 | // also our last, best chance for peace. 19 | func fileStreamReader(u *url.URL) (img.OpenStreamFunc, error) { 20 | if u.Scheme != "file" { 21 | return nil, plugins.ErrSkipped 22 | } 23 | 24 | return func() (img.Streamer, error) { return img.NewFileStream(u.Path) }, nil 25 | } 26 | 27 | // cloudStreamReader allows RAIS to read from a variety of cloud URLs, 28 | // including S3, Google Cloud, and Azure, as well as the local filesystem 29 | func cloudStreamReader(u *url.URL) (img.OpenStreamFunc, error) { 30 | return func() (img.Streamer, error) { return img.OpenStream(u) }, nil 31 | } 32 | -------------------------------------------------------------------------------- /src/cmd/rais-server/stats.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "sync" 6 | "sync/atomic" 7 | "time" 8 | ) 9 | 10 | type plugStats struct { 11 | Path string 12 | Functions []string 13 | } 14 | 15 | type cacheStats struct { 16 | GetCount uint64 17 | GetHits uint64 18 | SetCount uint64 19 | Length int 20 | m sync.Mutex 21 | Enabled bool 22 | HitPercent float64 23 | } 24 | 25 | func (cs *cacheStats) setHitPercent() { 26 | cs.m.Lock() 27 | defer cs.m.Unlock() 28 | if cs.GetCount == 0 { 29 | cs.HitPercent = 0 30 | return 31 | } 32 | cs.HitPercent = float64(cs.GetHits) / float64(cs.GetCount) 33 | } 34 | 35 | // Get increments GetCount safely 36 | func (cs *cacheStats) Get() { 37 | atomic.AddUint64(&cs.GetCount, 1) 38 | } 39 | 40 | // Hit increments GetHits safely 41 | func (cs *cacheStats) Hit() { 42 | atomic.AddUint64(&cs.GetHits, 1) 43 | } 44 | 45 | // Set increments SetCount safely 46 | func (cs *cacheStats) Set() { 47 | atomic.AddUint64(&cs.SetCount, 1) 48 | } 49 | 50 | // serverStats holds a bunch of global data. This is only threadsafe when 51 | // calling functions, so don't directly manipulate anything except when you 52 | // know only one thread can possibly exist! (e.g., when first setting up the 53 | // object) 54 | type serverStats struct { 55 | m sync.Mutex 56 | InfoCache cacheStats 57 | TileCache cacheStats 58 | Plugins []plugStats 59 | RAISVersion string 60 | ServerStart time.Time 61 | Uptime string 62 | } 63 | 64 | // Serialize writes the stats data to w in JSON format 65 | func (s *serverStats) Serialize() ([]byte, error) { 66 | s.calculateDerivedStats() 67 | return json.Marshal(s) 68 | } 69 | 70 | // calculateDerivedStats computes things we don't need to store real-time, such 71 | // as cache hit percent, uptime, etc. 72 | func (s *serverStats) calculateDerivedStats() { 73 | s.m.Lock() 74 | 75 | s.Uptime = time.Since(s.ServerStart).Round(time.Second).String() 76 | if infoCache != nil { 77 | s.InfoCache.setHitPercent() 78 | s.InfoCache.Length = infoCache.Len() 79 | } 80 | if tileCache != nil { 81 | s.TileCache.setHitPercent() 82 | s.TileCache.Length = tileCache.Len() 83 | } 84 | 85 | s.m.Unlock() 86 | } 87 | -------------------------------------------------------------------------------- /src/fakehttp/response_writer.go: -------------------------------------------------------------------------------- 1 | // Package fakehttp provides a fake response writer for use in tests. Further 2 | // http package mocks will probably not be created, as I'm sure there's a more 3 | // complete http mock library out there. 4 | package fakehttp 5 | 6 | import ( 7 | "net/http" 8 | ) 9 | 10 | // The ResponseWriter adheres to the http.ResposeWriter interface in a minimal 11 | // way to support easier testing 12 | type ResponseWriter struct { 13 | StatusCode int 14 | Headers http.Header 15 | Output []byte 16 | } 17 | 18 | // NewResponseWriter returns a ResponseWriter with a default status code of -1 19 | func NewResponseWriter() *ResponseWriter { 20 | return &ResponseWriter{ 21 | StatusCode: -1, 22 | Headers: make(http.Header), 23 | Output: make([]byte, 0), 24 | } 25 | } 26 | 27 | // Header returns the http.Header data for http.ResponseWriter compatibility 28 | func (rw *ResponseWriter) Header() http.Header { 29 | return rw.Headers 30 | } 31 | 32 | // Write stores the given output for later testing 33 | func (rw *ResponseWriter) Write(b []byte) (int, error) { 34 | lenCurr, lenNew := len(rw.Output), len(b) 35 | newOut := make([]byte, lenCurr+lenNew) 36 | copy(newOut, rw.Output) 37 | copy(newOut[lenCurr:], b) 38 | rw.Output = newOut 39 | return len(b), nil 40 | } 41 | 42 | // WriteHeader sets up StatusCode for later testing 43 | func (rw *ResponseWriter) WriteHeader(s int) { 44 | rw.StatusCode = s 45 | } 46 | -------------------------------------------------------------------------------- /src/fakehttp/response_writer_test.go: -------------------------------------------------------------------------------- 1 | package fakehttp 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestResponseWriter(t *testing.T) { 8 | rw := NewResponseWriter() 9 | rw.Write([]byte("foo")) 10 | rw.Write([]byte("bar")) 11 | 12 | if string(rw.Output) != "foobar" { 13 | t.Errorf("Expected %#v, but got %#v", "foobar", string(rw.Output)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/iiif/feature_levels.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | // FeatureSet0 returns a copy of the feature set required for a 4 | // level-0-compliant IIIF server 5 | func FeatureSet0() *FeatureSet { 6 | return &FeatureSet{ 7 | SizeByWhListed: true, 8 | Default: true, 9 | Jpg: true, 10 | } 11 | } 12 | 13 | // FeatureSet1 returns a copy of the feature set required for a 14 | // level-1-compliant IIIF server 15 | func FeatureSet1() *FeatureSet { 16 | return &FeatureSet{ 17 | RegionByPx: true, 18 | SizeByWhListed: true, 19 | SizeByW: true, 20 | SizeByH: true, 21 | SizeByPct: true, 22 | Default: true, 23 | Jpg: true, 24 | BaseURIRedirect: true, 25 | Cors: true, 26 | JsonldMediaType: true, 27 | } 28 | } 29 | 30 | // FeatureSet2 returns a copy of the feature set required for a 31 | // level-2-compliant IIIF server 32 | func FeatureSet2() *FeatureSet { 33 | return &FeatureSet{ 34 | RegionByPx: true, 35 | RegionByPct: true, 36 | SizeByWhListed: true, 37 | SizeByW: true, 38 | SizeByH: true, 39 | SizeByPct: true, 40 | SizeByForcedWh: true, 41 | SizeByWh: true, 42 | RotationBy90s: true, 43 | Default: true, 44 | Color: true, 45 | Gray: true, 46 | Bitonal: true, 47 | Jpg: true, 48 | Png: true, 49 | BaseURIRedirect: true, 50 | Cors: true, 51 | JsonldMediaType: true, 52 | } 53 | } 54 | 55 | // AllFeatures returns the complete list of everything supported by RAIS at 56 | // this time 57 | func AllFeatures() *FeatureSet { 58 | return &FeatureSet{ 59 | RegionByPx: true, 60 | RegionByPct: true, 61 | RegionSquare: true, 62 | 63 | SizeByWhListed: true, 64 | SizeByW: true, 65 | SizeByH: true, 66 | SizeByPct: true, 67 | SizeByWh: true, 68 | SizeByForcedWh: true, 69 | SizeAboveFull: true, 70 | SizeByConfinedWh: true, 71 | SizeByDistortedWh: true, 72 | 73 | RotationBy90s: true, 74 | Mirroring: true, 75 | 76 | Default: true, 77 | Color: true, 78 | Gray: true, 79 | Bitonal: true, 80 | 81 | Jpg: true, 82 | Png: true, 83 | Gif: false, 84 | Tif: true, 85 | 86 | BaseURIRedirect: true, 87 | Cors: true, 88 | JsonldMediaType: true, 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/iiif/feature_support.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | // Supported tells us whether or not the given feature set will actually 4 | // perform the operation represented by the URL instance. 5 | // 6 | // Unsupported functionality is expected to report an http status of 501. 7 | // 8 | // This doesn't actually work in all cases, such as a level 0 server that has 9 | // sizes explicitly listed for a given image resize operation. In those cases, 10 | // Supported() is probably not worth calling, instead handling just the few 11 | // supported cases directly and/or checking a custom featureset directly. 12 | // 13 | // This also doesn't actually check all possibly supported features - the URL 14 | // type is useful for parsing a URI path, but doesn't know about e.g. http 15 | // features. 16 | func (fs *FeatureSet) Supported(u *URL) bool { 17 | return fs.SupportsRegion(u.Region) && 18 | fs.SupportsSize(u.Size) && 19 | fs.SupportsRotation(u.Rotation) && 20 | fs.SupportsQuality(u.Quality) && 21 | fs.SupportsFormat(u.Format) 22 | } 23 | 24 | // SupportsRegion just verifies a given region type is supported 25 | func (fs *FeatureSet) SupportsRegion(r Region) bool { 26 | switch r.Type { 27 | case RTSquare: 28 | return fs.RegionSquare 29 | case RTPixel: 30 | return fs.RegionByPx 31 | case RTPercent: 32 | return fs.RegionByPct 33 | default: 34 | return true 35 | } 36 | } 37 | 38 | // SupportsSize just verifies a given size type is supported 39 | func (fs *FeatureSet) SupportsSize(s Size) bool { 40 | switch s.Type { 41 | case STScaleToWidth: 42 | return fs.SizeByW 43 | case STScaleToHeight: 44 | return fs.SizeByH 45 | case STScalePercent: 46 | return fs.SizeByPct 47 | case STExact: 48 | return fs.SizeByForcedWh 49 | case STBestFit: 50 | return fs.SizeByWh 51 | default: 52 | return true 53 | } 54 | } 55 | 56 | // SupportsRotation just verifies a given rotation type is supported 57 | func (fs *FeatureSet) SupportsRotation(r Rotation) bool { 58 | // We check mirroring specially in order to make the degree checks simple 59 | if r.Mirror && !fs.Mirroring { 60 | return false 61 | } 62 | 63 | switch r.Degrees { 64 | case 0: 65 | return true 66 | case 90, 180, 270: 67 | return fs.RotationBy90s || fs.RotationArbitrary 68 | default: 69 | return fs.RotationArbitrary 70 | } 71 | } 72 | 73 | // SupportsQuality just verifies a given quality type is supported 74 | func (fs *FeatureSet) SupportsQuality(q Quality) bool { 75 | switch q { 76 | case QColor: 77 | return fs.Color 78 | case QGray: 79 | return fs.Gray 80 | case QBitonal: 81 | return fs.Bitonal 82 | case QDefault, QNative: 83 | return fs.Default 84 | default: 85 | return false 86 | } 87 | } 88 | 89 | // SupportsFormat just verifies a given format type is supported 90 | func (fs *FeatureSet) SupportsFormat(f Format) bool { 91 | switch f { 92 | case FmtJPG: 93 | return fs.Jpg 94 | case FmtTIF: 95 | return fs.Tif 96 | case FmtPNG: 97 | return fs.Png 98 | case FmtGIF: 99 | return fs.Gif 100 | case FmtJP2: 101 | return fs.Jp2 102 | case FmtPDF: 103 | return fs.Pdf 104 | case FmtWEBP: 105 | return fs.Webp 106 | default: 107 | return false 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/iiif/features.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | // FeaturesMap is a simple map for boolean features, used for comparing 4 | // featuresets and reporting features beyond the reported level 5 | type FeaturesMap map[string]bool 6 | 7 | // TileSize represents a supported tile size for a feature set to expose. This 8 | // data is serialized in an info request and therefore must have JSON tags. 9 | type TileSize struct { 10 | Width int `json:"width"` 11 | Height int `json:"height,omitempty"` 12 | ScaleFactors []int `json:"scaleFactors"` 13 | } 14 | 15 | // FeatureSet represents possible IIIF 2.1 features. The boolean fields are 16 | // the same as the string to report features, except that the first character 17 | // should be lowercased. 18 | // 19 | // Note that using this in a different server only gets you so far. As noted 20 | // in the Supported() documentation below, verifying complete support is 21 | // trickier than just checking a URL, and a server that doesn't support 22 | // arbitrary resizing can still advertise specific sizes that will work. 23 | type FeatureSet struct { 24 | // Region options: note that full isn't specified but must be supported 25 | RegionByPx bool 26 | RegionByPct bool 27 | RegionSquare bool 28 | 29 | // Size options: note that full isn't specified but must be supported 30 | SizeByWhListed bool 31 | SizeByW bool 32 | SizeByH bool 33 | SizeByPct bool 34 | SizeByForcedWh bool 35 | SizeByWh bool 36 | SizeAboveFull bool 37 | SizeByConfinedWh bool 38 | SizeByDistortedWh bool 39 | 40 | // Rotation and mirroring 41 | RotationBy90s bool 42 | RotationArbitrary bool 43 | Mirroring bool 44 | 45 | // "Quality" (color model / color depth) 46 | Default bool 47 | Color bool 48 | Gray bool 49 | Bitonal bool 50 | 51 | // Format 52 | Jpg bool 53 | Png bool 54 | Tif bool 55 | Gif bool 56 | Jp2 bool 57 | Pdf bool 58 | Webp bool 59 | 60 | // HTTP features 61 | BaseURIRedirect bool 62 | Cors bool 63 | JsonldMediaType bool 64 | ProfileLinkHeader bool 65 | CanonicalLinkHeader bool 66 | 67 | // Non-boolean feature support 68 | TileSizes []TileSize 69 | } 70 | 71 | // toMap converts a FeatureSet's boolean support values into a map suitable for 72 | // use in comparison to other feature sets. The strings used are lowercased so 73 | // they can be used as-is within "formats", "qualities", and/or "supports" 74 | // arrays. 75 | func (fs *FeatureSet) toMap() FeaturesMap { 76 | return FeaturesMap{ 77 | "regionByPx": fs.RegionByPx, 78 | "regionByPct": fs.RegionByPct, 79 | "regionSquare": fs.RegionSquare, 80 | "sizeByWhListed": fs.SizeByWhListed, 81 | "sizeByW": fs.SizeByW, 82 | "sizeByH": fs.SizeByH, 83 | "sizeByPct": fs.SizeByPct, 84 | "sizeByForcedWh": fs.SizeByForcedWh, 85 | "sizeByWh": fs.SizeByWh, 86 | "sizeByConfinedWh": fs.SizeByConfinedWh, 87 | "sizeByDistortedWh": fs.SizeByDistortedWh, 88 | "sizeAboveFull": fs.SizeAboveFull, 89 | "rotationBy90s": fs.RotationBy90s, 90 | "rotationArbitrary": fs.RotationArbitrary, 91 | "mirroring": fs.Mirroring, 92 | "default": fs.Default, 93 | "color": fs.Color, 94 | "gray": fs.Gray, 95 | "bitonal": fs.Bitonal, 96 | "jpg": fs.Jpg, 97 | "png": fs.Png, 98 | "tif": fs.Tif, 99 | "gif": fs.Gif, 100 | "jp2": fs.Jp2, 101 | "pdf": fs.Pdf, 102 | "webp": fs.Webp, 103 | "baseUriRedirect": fs.BaseURIRedirect, 104 | "cors": fs.Cors, 105 | "jsonldMediaType": fs.JsonldMediaType, 106 | "profileLinkHeader": fs.ProfileLinkHeader, 107 | "canonicalLinkHeader": fs.CanonicalLinkHeader, 108 | } 109 | } 110 | 111 | // FeatureCompare returns which features are in common between two FeatureSets, 112 | // which are exclusive to a, and which are exclusive to b. The returned maps 113 | // will ONLY contain keys with a value of true, as opposed to the full list of 114 | // features and true/false. This helps to quickly determine equality, subset 115 | // status, and superset status. 116 | func FeatureCompare(a, b *FeatureSet) (union, onlyA, onlyB FeaturesMap) { 117 | union = make(FeaturesMap) 118 | onlyA = make(FeaturesMap) 119 | onlyB = make(FeaturesMap) 120 | 121 | mapA := a.toMap() 122 | mapB := b.toMap() 123 | 124 | for feature, supportedA := range mapA { 125 | supportedB := mapB[feature] 126 | if supportedA && supportedB { 127 | union[feature] = true 128 | continue 129 | } 130 | 131 | if supportedA { 132 | onlyA[feature] = true 133 | continue 134 | } 135 | 136 | if supportedB { 137 | onlyB[feature] = true 138 | } 139 | } 140 | 141 | return union, onlyA, onlyB 142 | } 143 | 144 | // includes returns whether or not fs includes all features in fsIncluded 145 | func (fs *FeatureSet) includes(fsIncluded *FeatureSet) bool { 146 | _, _, onlyYours := FeatureCompare(fs, fsIncluded) 147 | return len(onlyYours) == 0 148 | } 149 | -------------------------------------------------------------------------------- /src/iiif/format.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | // Format represents a IIIF 2.0 file format a client may request 4 | type Format string 5 | 6 | // All known file formats for IIIF 2.0 7 | const ( 8 | FmtUnknown Format = "" 9 | FmtJPG Format = "jpg" 10 | FmtTIF Format = "tif" 11 | FmtPNG Format = "png" 12 | FmtGIF Format = "gif" 13 | FmtJP2 Format = "jp2" 14 | FmtPDF Format = "pdf" 15 | FmtWEBP Format = "webp" 16 | ) 17 | 18 | // Formats is the definitive list of all possible Format constants 19 | var Formats = []Format{FmtJPG, FmtTIF, FmtPNG, FmtGIF, FmtJP2, FmtPDF, FmtWEBP} 20 | 21 | // StringToFormat converts val into a Format constant if val is one of our 22 | // valid Formats 23 | func StringToFormat(val string) Format { 24 | f := Format(val) 25 | if f.Valid() { 26 | return f 27 | } 28 | return FmtUnknown 29 | } 30 | 31 | // Valid returns whether a given Format string is valid. Since a Format can be 32 | // created via Format("blah"), this ensures the format is, in fact, within the 33 | // list of known formats. 34 | func (f Format) Valid() bool { 35 | for _, valid := range Formats { 36 | if valid == f { 37 | return true 38 | } 39 | } 40 | 41 | return false 42 | } 43 | -------------------------------------------------------------------------------- /src/iiif/format_test.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/uoregon-libraries/gopkg/assert" 7 | ) 8 | 9 | func TestFormatValidity(t *testing.T) { 10 | formats := []string{"jpg", "tif", "png", "gif", "jp2", "pdf", "webp"} 11 | for _, f := range formats { 12 | assert.True(Format(f).Valid(), f+" is a valid format", t) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/iiif/iiif.go: -------------------------------------------------------------------------------- 1 | // Package iiif defines various parsing options and compliance-level 2 | // information for dealing with a web request for a IIIF resource/operation 3 | package iiif 4 | -------------------------------------------------------------------------------- /src/iiif/info.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "sort" 7 | ) 8 | 9 | // ProfileWrapper is a structure which has to custom-marshal itself to provide 10 | // the rather awful profile IIIF defines: it's an array with any number of 11 | // elements, where the first element is always a string (URI to the conformance 12 | // level) and subsequent elements are complex structures defining further 13 | // capabilities. 14 | type ProfileWrapper struct { 15 | profileElement2 16 | ConformanceURL string 17 | } 18 | 19 | // profileElement2 holds the pieces of the profile which can be marshaled into 20 | // JSON without crazy pain - they just have to be marshaled as the second 21 | // element in the aforementioned typeless profile array. 22 | type profileElement2 struct { 23 | Formats []string `json:"formats,omitempty"` 24 | Qualities []string `json:"qualities,omitempty"` 25 | Supports []string `json:"supports,omitempty"` 26 | MaxArea int64 `json:"maxArea,omitempty"` 27 | MaxWidth int `json:"maxWidth,omitempty"` 28 | MaxHeight int `json:"maxHeight,omitempty"` 29 | } 30 | 31 | // MarshalJSON implements json.Marshaler 32 | func (p *ProfileWrapper) MarshalJSON() ([]byte, error) { 33 | var hack = make([]any, 2) 34 | hack[0] = p.ConformanceURL 35 | hack[1] = p.profileElement2 36 | 37 | return json.Marshal(hack) 38 | } 39 | 40 | // UnmarshalJSON implements json.Unmarshaler 41 | func (p *ProfileWrapper) UnmarshalJSON(data []byte) error { 42 | var hack = make([]any, 2) 43 | hack[0] = "" 44 | hack[1] = &profileElement2{} 45 | 46 | var err = json.Unmarshal(data, &hack) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | switch v := hack[0].(type) { 52 | case string: 53 | p.ConformanceURL = v 54 | default: 55 | return fmt.Errorf("profile[0] (%#v) should have been a string", v) 56 | } 57 | 58 | switch v := hack[1].(type) { 59 | case *profileElement2: 60 | p.profileElement2 = *v 61 | default: 62 | return fmt.Errorf("profile[1] (%#v) should have been a structure", v) 63 | } 64 | 65 | return nil 66 | } 67 | 68 | // Info represents the simplest possible data to provide a valid IIIF 69 | // information JSON response 70 | type Info struct { 71 | Context string `json:"@context"` 72 | ID string `json:"@id"` 73 | Protocol string `json:"protocol"` 74 | Width int `json:"width"` 75 | Height int `json:"height"` 76 | Tiles []TileSize `json:"tiles,omitempty"` 77 | Profile ProfileWrapper `json:"profile"` 78 | } 79 | 80 | // NewInfo returns the static *Info data that's the same for any info response 81 | func NewInfo() *Info { 82 | return &Info{ 83 | Context: "http://iiif.io/api/image/2/context.json", 84 | Protocol: "http://iiif.io/api/image", 85 | } 86 | } 87 | 88 | // Info returns the default structure for a FeatureSet's info response JSON. 89 | // The caller is responsible for filling in image-specific values (ID and 90 | // dimensions). 91 | func (fs *FeatureSet) Info() *Info { 92 | i := NewInfo() 93 | i.Profile = fs.Profile() 94 | 95 | return i 96 | } 97 | 98 | // baseFeatureSetData returns a FeatureSet instance for the base level as well 99 | // as the profile URI for a given feature level 100 | func (fs *FeatureSet) baseFeatureSet() (*FeatureSet, string) { 101 | featuresLevel2 := FeatureSet2() 102 | if fs.includes(featuresLevel2) { 103 | return featuresLevel2, "http://iiif.io/api/image/2/level2.json" 104 | } 105 | 106 | featuresLevel1 := FeatureSet1() 107 | if fs.includes(featuresLevel1) { 108 | return featuresLevel1, "http://iiif.io/api/image/2/level1.json" 109 | } 110 | 111 | return FeatureSet0(), "http://iiif.io/api/image/2/level0.json" 112 | } 113 | 114 | // Profile examines the features in the FeatureSet to determine first which 115 | // level the FeatureSet supports, then adds any variances. 116 | func (fs *FeatureSet) Profile() ProfileWrapper { 117 | baseFS, u := fs.baseFeatureSet() 118 | p := ProfileWrapper{ConformanceURL: u} 119 | 120 | _, extraFeatures, _ := FeatureCompare(fs, baseFS) 121 | if len(extraFeatures) > 0 { 122 | p.profileElement2 = extraProfileFromFeaturesMap(extraFeatures) 123 | } 124 | 125 | return p 126 | } 127 | 128 | func extraProfileFromFeaturesMap(fm FeaturesMap) profileElement2 { 129 | p := profileElement2{ 130 | Formats: make([]string, 0), 131 | Qualities: make([]string, 0), 132 | Supports: make([]string, 0), 133 | } 134 | 135 | // By default a FeaturesMap is created only listing enabled features, so as 136 | // long as that doesn't change, we can ignore the boolean 137 | for name := range fm { 138 | if Quality(name).Valid() { 139 | p.Qualities = append(p.Qualities, name) 140 | continue 141 | } 142 | if Format(name).Valid() { 143 | p.Formats = append(p.Formats, name) 144 | continue 145 | } 146 | 147 | p.Supports = append(p.Supports, name) 148 | } 149 | 150 | sort.Strings(p.Qualities) 151 | sort.Strings(p.Formats) 152 | sort.Strings(p.Supports) 153 | 154 | return p 155 | } 156 | -------------------------------------------------------------------------------- /src/iiif/info_test.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/uoregon-libraries/gopkg/assert" 7 | ) 8 | 9 | func TestSimpleInfoProfile(t *testing.T) { 10 | fs := FeatureSet1() 11 | i := fs.Info() 12 | assert.Equal("http://iiif.io/api/image/2/level1.json", i.Profile.ConformanceURL, "Profile is level 1", t) 13 | 14 | extra := i.Profile.profileElement2 15 | assert.Equal(0, len(extra.Supports), "extra supports", t) 16 | assert.Equal(0, len(extra.Qualities), "extra qualities", t) 17 | assert.Equal(0, len(extra.Formats), "extra formats", t) 18 | } 19 | 20 | // Removing a single item from level 1 should result in a level 0 profile that 21 | // adds a bunch of features 22 | func TestLevel1MissingFeaturesProfile(t *testing.T) { 23 | fs := FeatureSet1() 24 | fs.SizeByPct = false 25 | i := fs.Info() 26 | assert.Equal("http://iiif.io/api/image/2/level0.json", i.Profile.ConformanceURL, "Profile is level 0", t) 27 | 28 | extra := i.Profile.profileElement2 29 | assert.Equal(6, len(extra.Supports), "There are 6 extra features", t) 30 | assert.Equal(0, len(extra.Qualities), "There are 0 extra qualities", t) 31 | assert.Equal(0, len(extra.Formats), "There are 0 extra formats", t) 32 | assert.IncludesString("regionByPx", extra.Supports, "Custom FS support", t) 33 | assert.IncludesString("sizeByW", extra.Supports, "Custom FS support", t) 34 | assert.IncludesString("sizeByH", extra.Supports, "Custom FS support", t) 35 | assert.IncludesString("baseUriRedirect", extra.Supports, "Custom FS support", t) 36 | assert.IncludesString("cors", extra.Supports, "Custom FS support", t) 37 | assert.IncludesString("jsonldMediaType", extra.Supports, "Custom FS support", t) 38 | 39 | // Just for kicks, maybe let's verify some formats and qualities 40 | fs.Color = true 41 | fs.Bitonal = true 42 | fs.Png = true 43 | fs.Pdf = true 44 | fs.Jp2 = true 45 | fs.Gif = true 46 | i = fs.Info() 47 | extra = i.Profile.profileElement2 48 | assert.Equal(2, len(extra.Qualities), "There are 2 extra qualities now", t) 49 | assert.Equal(4, len(extra.Formats), "There are 4 extra formats now", t) 50 | assert.IncludesString("color", extra.Qualities, "Extra quality support", t) 51 | assert.IncludesString("bitonal", extra.Qualities, "Extra quality support", t) 52 | assert.IncludesString("png", extra.Formats, "Extra format support", t) 53 | assert.IncludesString("pdf", extra.Formats, "Extra format support", t) 54 | assert.IncludesString("jp2", extra.Formats, "Extra format support", t) 55 | assert.IncludesString("gif", extra.Formats, "Extra format support", t) 56 | } 57 | 58 | func TestAllFeaturesEnabled(t *testing.T) { 59 | fs := AllFeatures() 60 | i := fs.Info() 61 | assert.Equal("http://iiif.io/api/image/2/level2.json", i.Profile.ConformanceURL, "Profile conformance level", t) 62 | 63 | extra := i.Profile.profileElement2 64 | assert.Equal(5, len(extra.Supports), "THERE... ARE... FOUR... (plus one) EXTRA... FEATURES!", t) 65 | assert.Equal(0, len(extra.Qualities), "There are 0 extra qualities", t) 66 | assert.Equal(1, len(extra.Formats), "There is 1 extra format", t) 67 | assert.IncludesString("regionSquare", extra.Supports, "Custom FS support", t) 68 | assert.IncludesString("sizeAboveFull", extra.Supports, "Custom FS support", t) 69 | assert.IncludesString("mirroring", extra.Supports, "Custom FS support", t) 70 | assert.IncludesString("tif", extra.Formats, "Custom FS support", t) 71 | } 72 | -------------------------------------------------------------------------------- /src/iiif/quality.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | // Quality is the representation of a IIIF 2.0 quality (color space / depth) 4 | // which a client may request. We also include "native" for better 5 | // compatibility with older clients, since it's the same as "default". 6 | type Quality string 7 | 8 | // All possible qualities for IIIF 2.0 and 1.1 9 | const ( 10 | QUnknown Quality = "" 11 | QColor Quality = "color" 12 | QGray Quality = "gray" 13 | QBitonal Quality = "bitonal" 14 | QDefault Quality = "default" 15 | QNative Quality = "native" // For 1.1 compatibility 16 | ) 17 | 18 | // Qualities is the definitive list of all possible Quality constants 19 | var Qualities = []Quality{QColor, QGray, QBitonal, QDefault, QNative} 20 | 21 | // StringToQuality converts val into a Quality constant if val is one of our 22 | // valid Qualities 23 | func StringToQuality(val string) Quality { 24 | q := Quality(val) 25 | if q.Valid() { 26 | return q 27 | } 28 | return QUnknown 29 | } 30 | 31 | // Valid returns whether a given Quality string is valid. Since a Quality can be 32 | // created via Quality("blah"), this ensures the quality is, in fact, within the 33 | // list of known qualities. 34 | func (q Quality) Valid() bool { 35 | for _, valid := range Qualities { 36 | if valid == q { 37 | return true 38 | } 39 | } 40 | 41 | return false 42 | } 43 | -------------------------------------------------------------------------------- /src/iiif/region.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | import ( 4 | "image" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // A RegionType tells us what a Region is representing so we know how to apply 10 | // the x/y/w/h values 11 | type RegionType int 12 | 13 | const ( 14 | // RTNone means we didn't find a valid region string 15 | RTNone RegionType = iota 16 | // RTFull means we ignore x/y/w/h and use the whole image 17 | RTFull 18 | // RTPercent means we interpret x/y/w/h as percentages of the image size 19 | RTPercent 20 | // RTPixel means we interpret x/y/w/h as precise coordinates within the image 21 | RTPixel 22 | // RTSquare means a square region where w/h are the image's shortest dimension 23 | RTSquare 24 | ) 25 | 26 | // Region represents the part of the image we'll manipulate. It can be thought 27 | // of as the cropping rectangle. 28 | type Region struct { 29 | Type RegionType 30 | X, Y, W, H float64 31 | } 32 | 33 | // StringToRegion takes a string representing a region, as seen in a IIIF URL, 34 | // and fills in the values based on the string's format. 35 | func StringToRegion(p string) Region { 36 | if p == "full" { 37 | return Region{Type: RTFull} 38 | } 39 | if p == "square" { 40 | return Region{Type: RTSquare} 41 | } 42 | 43 | r := Region{Type: RTPixel} 44 | if len(p) > 4 && p[0:4] == "pct:" { 45 | r.Type = RTPercent 46 | p = p[4:] 47 | } 48 | 49 | vals := strings.Split(p, ",") 50 | if len(vals) < 4 { 51 | return Region{Type: RTNone} 52 | } 53 | 54 | r.X, _ = strconv.ParseFloat(vals[0], 64) 55 | r.Y, _ = strconv.ParseFloat(vals[1], 64) 56 | r.W, _ = strconv.ParseFloat(vals[2], 64) 57 | r.H, _ = strconv.ParseFloat(vals[3], 64) 58 | 59 | return r 60 | } 61 | 62 | // Valid checks for (a) a known region type, and then (b) verifies that the 63 | // values are valid for the given type. There is no attempt to check for 64 | // per-image correctness, just general validity. 65 | func (r Region) Valid() bool { 66 | switch r.Type { 67 | case RTNone: 68 | return false 69 | case RTFull, RTSquare: 70 | return true 71 | } 72 | 73 | if r.W <= 0 || r.H <= 0 || r.X < 0 || r.Y < 0 { 74 | return false 75 | } 76 | 77 | if r.Type == RTPercent && (r.X+r.W > 100 || r.Y+r.H > 100) { 78 | return false 79 | } 80 | 81 | return true 82 | } 83 | 84 | // GetCrop determines the cropped area that this region represents given an 85 | // image width and height 86 | func (r Region) GetCrop(w, h int) image.Rectangle { 87 | crop := image.Rect(0, 0, w, h) 88 | 89 | switch r.Type { 90 | case RTSquare: 91 | if w < h { 92 | top := (h - w) / 2 93 | crop = image.Rect(0, top, w, w+top) 94 | } else if h < w { 95 | left := (w - h) / 2 96 | crop = image.Rect(left, 0, h+left, h) 97 | } 98 | case RTPixel: 99 | crop = image.Rect(int(r.X), int(r.Y), int(r.X+r.W), int(r.Y+r.H)) 100 | case RTPercent: 101 | crop = image.Rect( 102 | int(r.X*float64(w)/100.0), 103 | int(r.Y*float64(h)/100.0), 104 | int((r.X+r.W)*float64(w)/100.0), 105 | int((r.Y+r.H)*float64(h)/100.0), 106 | ) 107 | } 108 | 109 | return crop 110 | } 111 | -------------------------------------------------------------------------------- /src/iiif/region_test.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/uoregon-libraries/gopkg/assert" 7 | ) 8 | 9 | func TestRegionTypePercent(t *testing.T) { 10 | r := StringToRegion("pct:41.6,7.5,40,70") 11 | assert.True(r.Type == RTPercent, "r.Type == RTPercent", t) 12 | assert.Equal(41.6, r.X, "r.X", t) 13 | assert.Equal(7.5, r.Y, "r.Y", t) 14 | assert.Equal(40.0, r.W, "r.W", t) 15 | assert.Equal(70.0, r.H, "r.H", t) 16 | } 17 | 18 | func TestRegionTypePixels(t *testing.T) { 19 | r := StringToRegion("10,10,40,70") 20 | assert.True(r.Valid(), "r.Valid()", t) 21 | assert.True(r.Type == RTPixel, "r.Type == RTPixel", t) 22 | assert.Equal(10.0, r.X, "r.X", t) 23 | assert.Equal(10.0, r.Y, "r.Y", t) 24 | assert.Equal(40.0, r.W, "r.W", t) 25 | assert.Equal(70.0, r.H, "r.H", t) 26 | } 27 | 28 | func TestInvalidRegion(t *testing.T) { 29 | r := StringToRegion("10,10,0,70") 30 | assert.True(!r.Valid(), "!r.Valid()", t) 31 | r = StringToRegion("10,10,40,0") 32 | assert.True(!r.Valid(), "!r.Valid()", t) 33 | r = Region{} 34 | assert.True(!r.Valid(), "!r.Valid()", t) 35 | } 36 | 37 | func TestRegionTypeFull(t *testing.T) { 38 | r := StringToRegion("full") 39 | assert.True(r.Type == RTFull, "r.Type == RTFull", t) 40 | } 41 | 42 | func TestRegionTypeSquare(t *testing.T) { 43 | r := StringToRegion("square") 44 | assert.True(r.Type == RTSquare, "r.Type == RTSquare", t) 45 | } 46 | -------------------------------------------------------------------------------- /src/iiif/rotation.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | import ( 4 | "strconv" 5 | ) 6 | 7 | // Rotation represents the degrees of rotation and whether or not an image is 8 | // mirrored, as both are defined in IIIF 2.0 as being part of the rotation 9 | // parameter in IIIF URL requests. 10 | type Rotation struct { 11 | Mirror bool 12 | Degrees float64 13 | } 14 | 15 | // StringToRotation creates a Rotation from a string as seen in a IIIF URL. 16 | // An invalid string would result in a 0-degree rotation as opposed to an error 17 | // condition. This is a known issue which needs to be fixed. 18 | func StringToRotation(p string) Rotation { 19 | r := Rotation{} 20 | if p == "" { 21 | return r 22 | } 23 | if p[0:1] == "!" { 24 | r.Mirror = true 25 | p = p[1:] 26 | } 27 | 28 | r.Degrees, _ = strconv.ParseFloat(p, 64) 29 | 30 | // This isn't actually to spec, but it makes way more sense than only 31 | // allowing 360 for compliance level 2 (and in fact *requiring* it there) 32 | if r.Degrees == 360 { 33 | r.Degrees = 0 34 | } 35 | 36 | return r 37 | } 38 | 39 | // Valid just returns whether or not the degrees value is within a sane range: 40 | // 0 <= r.Degrees < 360 41 | func (r Rotation) Valid() bool { 42 | return r.Degrees >= 0 && r.Degrees < 360 43 | } 44 | -------------------------------------------------------------------------------- /src/iiif/rotation_test.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/uoregon-libraries/gopkg/assert" 7 | ) 8 | 9 | func TestRotationNormal(t *testing.T) { 10 | r := StringToRotation("250.5") 11 | assert.Equal(250.5, r.Degrees, "r.Degrees", t) 12 | assert.True(!r.Mirror, "!r.Mirror", t) 13 | } 14 | 15 | func TestRotationMirrored(t *testing.T) { 16 | r := StringToRotation("!90") 17 | assert.Equal(90.0, r.Degrees, "r.Degrees", t) 18 | assert.True(r.Mirror, "r.Mirror", t) 19 | } 20 | 21 | func TestRotation360(t *testing.T) { 22 | r := StringToRotation("360.0") 23 | assert.True(r.Valid(), "r.Valid", t) 24 | assert.Equal(0.0, r.Degrees, "r.Degrees", t) 25 | } 26 | 27 | func TestInvalidRotation(t *testing.T) { 28 | r := Rotation{Degrees: -1} 29 | assert.True(!r.Valid(), "!r.Valid", t) 30 | r = StringToRotation("!-1") 31 | assert.True(!r.Valid(), "!r.Valid", t) 32 | r = StringToRotation("360.1") 33 | assert.True(!r.Valid(), "!r.Valid", t) 34 | r = StringToRotation("!360.1") 35 | assert.True(!r.Valid(), "!r.Valid", t) 36 | } 37 | -------------------------------------------------------------------------------- /src/iiif/size.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | import ( 4 | "image" 5 | "math" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // SizeType represents the type of scaling which will be performed 11 | type SizeType int 12 | 13 | const ( 14 | // STNone is used when the Size struct wasn't able to be parsed form a string 15 | STNone SizeType = iota 16 | // STMax requests the maximum size the server supports 17 | STMax 18 | // STFull means no scaling is requested 19 | STFull 20 | // STScaleToWidth requests the image be scaled to a set width (aspect ratio 21 | // is preserved) 22 | STScaleToWidth 23 | // STScaleToHeight requests the image be scaled to a set height (aspect ratio 24 | // is preserved) 25 | STScaleToHeight 26 | // STScalePercent requests the image be scaled by a set percent of its size 27 | // (aspect ratio is preserved) 28 | STScalePercent 29 | // STExact requests the image be resized to precise width and height 30 | // dimensions (aspect ratio is not preserved) 31 | STExact 32 | // STBestFit requests the image be resized *near* the given width and height 33 | // dimensions (aspect ratio is preserved) 34 | STBestFit 35 | ) 36 | 37 | // Size represents the type of scaling as well as the parameters for scaling 38 | // for a IIIF 2.0 server 39 | type Size struct { 40 | Type SizeType 41 | Percent float64 42 | W, H int 43 | } 44 | 45 | // StringToSize creates a Size from a string as seen in a IIIF URL. 46 | func StringToSize(p string) Size { 47 | if p == "" { 48 | return Size{} 49 | } 50 | 51 | if p == "full" { 52 | return Size{Type: STFull} 53 | } 54 | if p == "max" { 55 | return Size{Type: STMax} 56 | } 57 | 58 | s := Size{Type: STNone} 59 | 60 | if len(p) > 4 && p[0:4] == "pct:" { 61 | s.Type = STScalePercent 62 | s.Percent, _ = strconv.ParseFloat(p[4:], 64) 63 | return s 64 | } 65 | 66 | if p[0:1] == "!" { 67 | s.Type = STBestFit 68 | p = p[1:] 69 | } 70 | 71 | vals := strings.Split(p, ",") 72 | if len(vals) != 2 { 73 | return s 74 | } 75 | s.W, _ = strconv.Atoi(vals[0]) 76 | s.H, _ = strconv.Atoi(vals[1]) 77 | 78 | if s.Type == STNone { 79 | if vals[0] == "" { 80 | s.Type = STScaleToHeight 81 | } else if vals[1] == "" { 82 | s.Type = STScaleToWidth 83 | } else { 84 | s.Type = STExact 85 | } 86 | } 87 | 88 | return s 89 | } 90 | 91 | // Valid returns whether the size has a valid type, and if so, whether the 92 | // parameters are valid for that type 93 | func (s Size) Valid() bool { 94 | switch s.Type { 95 | case STFull, STMax: 96 | return true 97 | case STScaleToWidth: 98 | return s.W > 0 99 | case STScaleToHeight: 100 | return s.H > 0 101 | case STScalePercent: 102 | return s.Percent > 0 103 | case STExact, STBestFit: 104 | return s.W > 0 && s.H > 0 105 | } 106 | 107 | return false 108 | } 109 | 110 | // GetResize determines how a given region would be resized and returns a 111 | // rectangle representing the scaled image's dimensions. If STMax is in use, 112 | // this returns the full region, as only the image server itself would know its 113 | // capabilities and therefore it shouldn't call this in that scenario. 114 | func (s Size) GetResize(region image.Rectangle) image.Rectangle { 115 | w, h := region.Dx(), region.Dy() 116 | 117 | var cloned = s 118 | switch s.Type { 119 | case STScaleToWidth: 120 | cloned.H = math.MaxInt32 121 | w, h = cloned.getBestFit(w, h) 122 | case STScaleToHeight: 123 | cloned.W = math.MaxInt32 124 | w, h = cloned.getBestFit(w, h) 125 | case STExact: 126 | w, h = cloned.W, cloned.H 127 | case STBestFit: 128 | w, h = cloned.getBestFit(w, h) 129 | case STScalePercent: 130 | w = int(float64(w) * cloned.Percent / 100.0) 131 | h = int(float64(h) * cloned.Percent / 100.0) 132 | } 133 | 134 | return image.Rect(0, 0, w, h) 135 | } 136 | 137 | // getBestFit preserves the aspect ratio while determining the proper scaling 138 | // factor to get width and height adjusted to fit within the width and height 139 | // of the desired size operation 140 | func (s Size) getBestFit(w, h int) (width int, height int) { 141 | fW, fH, fsW, fsH := float64(w), float64(h), float64(s.W), float64(s.H) 142 | sf := fsW / fW 143 | if sf*fH > fsH { 144 | sf = fsH / fH 145 | } 146 | return int(sf * fW), int(sf * fH) 147 | } 148 | -------------------------------------------------------------------------------- /src/iiif/size_test.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | 7 | "github.com/uoregon-libraries/gopkg/assert" 8 | ) 9 | 10 | func TestSizeTypeFull(t *testing.T) { 11 | s := StringToSize("full") 12 | assert.True(s.Valid(), "s.Valid()", t) 13 | assert.Equal(STFull, s.Type, "s.Type == STFull", t) 14 | } 15 | 16 | func TestSizeTypeScaleWidth(t *testing.T) { 17 | s := StringToSize("125,") 18 | assert.True(s.Valid(), "s.Valid()", t) 19 | assert.Equal(STScaleToWidth, s.Type, "s.Type == STScaleToWidth", t) 20 | assert.Equal(125, s.W, "s.W", t) 21 | assert.Equal(0, s.H, "s.H", t) 22 | } 23 | 24 | func TestSizeTypeScaleHeight(t *testing.T) { 25 | s := StringToSize(",250") 26 | assert.True(s.Valid(), "s.Valid()", t) 27 | assert.Equal(STScaleToHeight, s.Type, "s.Type == STScaleToHeight", t) 28 | assert.Equal(0, s.W, "s.W", t) 29 | assert.Equal(250, s.H, "s.H", t) 30 | } 31 | 32 | func TestSizeTypePercent(t *testing.T) { 33 | s := StringToSize("pct:41.6") 34 | assert.True(s.Valid(), "s.Valid()", t) 35 | assert.Equal(STScalePercent, s.Type, "s.Type == STScalePercent", t) 36 | assert.Equal(41.6, s.Percent, "s.Percent", t) 37 | } 38 | 39 | func TestSizeTypeExact(t *testing.T) { 40 | s := StringToSize("125,250") 41 | assert.True(s.Valid(), "s.Valid()", t) 42 | assert.Equal(STExact, s.Type, "s.Type == STExact", t) 43 | assert.Equal(125, s.W, "s.W", t) 44 | assert.Equal(250, s.H, "s.H", t) 45 | } 46 | 47 | func TestSizeTypeBestFit(t *testing.T) { 48 | s := StringToSize("!25,50") 49 | assert.True(s.Valid(), "s.Valid()", t) 50 | assert.Equal(STBestFit, s.Type, "s.Type == STBestFit", t) 51 | assert.Equal(25, s.W, "s.W", t) 52 | assert.Equal(50, s.H, "s.H", t) 53 | } 54 | 55 | func TestInvalidSizes(t *testing.T) { 56 | s := Size{} 57 | assert.True(!s.Valid(), "!s.Valid()", t) 58 | s = StringToSize(",0") 59 | assert.True(!s.Valid(), "!s.Valid()", t) 60 | s = StringToSize("0,") 61 | assert.True(!s.Valid(), "!s.Valid()", t) 62 | s = StringToSize("0,100") 63 | assert.True(!s.Valid(), "!s.Valid()", t) 64 | s = StringToSize("100,0") 65 | assert.True(!s.Valid(), "!s.Valid()", t) 66 | s = StringToSize("!0,100") 67 | assert.True(!s.Valid(), "!s.Valid()", t) 68 | s = StringToSize("!100,0") 69 | assert.True(!s.Valid(), "!s.Valid()", t) 70 | s = StringToSize("pct:0") 71 | assert.True(!s.Valid(), "!s.Valid()", t) 72 | } 73 | 74 | func TestGetResize(t *testing.T) { 75 | s := Size{Type: STFull} 76 | source := image.Rect(0, 0, 600, 1200) 77 | scale := s.GetResize(source) 78 | assert.Equal(scale.Dx(), source.Dx(), "full resize Dx", t) 79 | assert.Equal(scale.Dy(), source.Dy(), "full resize Dy", t) 80 | 81 | s.Type = STScaleToWidth 82 | s.W = 90 83 | scale = s.GetResize(source) 84 | assert.Equal(scale.Dx(), 90, "scale-to-width Dx", t) 85 | assert.Equal(scale.Dy(), 180, "scale-to-width Dy", t) 86 | 87 | s.Type = STScaleToHeight 88 | s.H = 90 89 | scale = s.GetResize(source) 90 | assert.Equal(scale.Dx(), 45, "scale-to-height Dx", t) 91 | assert.Equal(scale.Dy(), 90, "scale-to-height Dy", t) 92 | 93 | s.Type = STScalePercent 94 | s.Percent = 100 * 2.0 / 3.0 95 | scale = s.GetResize(source) 96 | assert.Equal(scale.Dx(), 400, "scale-to-pct Dx", t) 97 | assert.Equal(scale.Dy(), 800, "scale-to-pct Dy", t) 98 | 99 | s.Type = STExact 100 | s.W = 95 101 | s.H = 100 102 | scale = s.GetResize(source) 103 | assert.Equal(scale.Dx(), 95, "scale-to-exact Dx", t) 104 | assert.Equal(scale.Dy(), 100, "scale-to-exact Dy", t) 105 | 106 | s.Type = STBestFit 107 | scale = s.GetResize(source) 108 | assert.Equal(scale.Dx(), 50, "scale-to-pct Dx", t) 109 | assert.Equal(scale.Dy(), 100, "scale-to-pct Dy", t) 110 | } 111 | -------------------------------------------------------------------------------- /src/iiif/url.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "strings" 7 | ) 8 | 9 | // ID is a string identifying a particular file to process. It should be 10 | // unescaped for use if it's coming from a URL via URLToID(). 11 | type ID string 12 | 13 | // URLToID converts a value pulled from a URL into a suitable IIIF ID (by unescaping it) 14 | func URLToID(val string) ID { 15 | s, _ := url.QueryUnescape(string(val)) 16 | return ID(s) 17 | } 18 | 19 | // Escaped returns an escaped version of the ID, suitable for use in a URL 20 | func (id ID) Escaped() string { 21 | return url.QueryEscape(string(id)) 22 | } 23 | 24 | // URL represents the different options composed into a IIIF URL request 25 | type URL struct { 26 | Path string 27 | ID ID 28 | Region Region 29 | Size Size 30 | Rotation Rotation 31 | Quality Quality 32 | Format Format 33 | Info bool 34 | } 35 | 36 | type pathParts struct { 37 | data []string 38 | } 39 | 40 | func pathify(pth string) *pathParts { 41 | return &pathParts{data: strings.Split(pth, "/")} 42 | } 43 | 44 | // pop implements a hacky but fast and effective "pop" operation that just 45 | // returns a blank string when there's nothing left to pop 46 | func (p *pathParts) pop() string { 47 | var retval string 48 | if len(p.data) > 0 { 49 | retval, p.data = p.data[len(p.data)-1], p.data[:len(p.data)-1] 50 | } 51 | return retval 52 | } 53 | 54 | func (p *pathParts) rejoin() string { 55 | return strings.Join(p.data, "/") 56 | } 57 | 58 | // NewURL takes a path string (no scheme, server, or prefix, just the IIIF 59 | // pieces), such as "path%2Fto%2Fsomefile.jp2/full/512,/270/default.jpg", and 60 | // breaks it down into the different components. In this example: 61 | // 62 | // - ID: "path%2Fto%2Fsomefile.jp2" (the server determines how to find the image) 63 | // - Region: "full" (the whole image is processed) 64 | // - Size: "512," (the image is resized to a width of 512; aspect ratio is maintained) 65 | // - Rotation: "270" (the image is rotated 270 degrees clockwise) 66 | // - Quality: "default" (the image color space is unchanged) 67 | // - Format: "jpg" (the resulting image will be a JPEG) 68 | // 69 | // It's possible to get a URL and an error since an id-only request could 70 | // theoretically exist for a resource with *any* id. In those cases it's up to 71 | // the caller to figure out what to do - the returned URL will have as much 72 | // information as we're able to parse. 73 | func NewURL(path string) (*URL, error) { 74 | var u = &URL{Path: path} 75 | 76 | // Check for an info request first since it's pretty trivial to do 77 | if strings.HasSuffix(path, "info.json") { 78 | u.Info = true 79 | u.ID = URLToID(strings.Replace(path, "/info.json", "", -1)) 80 | return u, nil 81 | } 82 | 83 | // Parse in reverse order to deal with the continuing problem of slashes not 84 | // being escaped properly in all situations 85 | var parts = pathify(path) 86 | var qualityFormat = parts.pop() 87 | var qfParts = strings.SplitN(qualityFormat, ".", 2) 88 | if len(qfParts) == 2 { 89 | u.Format = StringToFormat(qfParts[1]) 90 | u.Quality = StringToQuality(qfParts[0]) 91 | } 92 | u.Rotation = StringToRotation(parts.pop()) 93 | u.Size = StringToSize(parts.pop()) 94 | u.Region = StringToRegion(parts.pop()) 95 | 96 | // The remainder of the path has to be the ID 97 | u.ID = URLToID(parts.rejoin()) 98 | 99 | // Invalid may or may not actually mean invalid, but we just let the caller 100 | // try to figure it out.... 101 | if !u.Valid() { 102 | return u, u.Error() 103 | } 104 | 105 | return u, nil 106 | } 107 | 108 | // Valid returns the validity of the request - is the syntax is bad in any way? 109 | // Are any numbers outside a set range? Was the identifier blank? Etc. 110 | // 111 | // Invalid requests are expected to report an http status of 400. 112 | func (u *URL) Valid() bool { 113 | return u.Error() == nil 114 | } 115 | 116 | // Error returns an error specifying invalid parts of the URL 117 | func (u *URL) Error() error { 118 | var messages []string 119 | if u.ID == "" { 120 | messages = append(messages, "empty id") 121 | } 122 | if !u.Region.Valid() { 123 | messages = append(messages, "invalid region") 124 | } 125 | if !u.Size.Valid() { 126 | messages = append(messages, "invalid size") 127 | } 128 | if !u.Rotation.Valid() { 129 | messages = append(messages, "invalid rotation") 130 | } 131 | if !u.Quality.Valid() { 132 | messages = append(messages, "invalid quality") 133 | } 134 | if !u.Format.Valid() { 135 | messages = append(messages, "invalid format") 136 | } 137 | 138 | if len(messages) > 0 { 139 | return errors.New(strings.Join(messages, ", ")) 140 | } 141 | return nil 142 | } 143 | -------------------------------------------------------------------------------- /src/iiif/url_test.go: -------------------------------------------------------------------------------- 1 | package iiif 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/uoregon-libraries/gopkg/assert" 10 | ) 11 | 12 | var weirdID = "identifier-foo-bar/baz,,,,,chameleon" 13 | var simplePath = url.QueryEscape(weirdID) + "/full/full/30/default.jpg" 14 | 15 | func TestInvalid(t *testing.T) { 16 | badURL := strings.Replace(simplePath, "/full/full", "/bad/full", 1) 17 | badURL = strings.Replace(badURL, "default.jpg", "default.foo", 1) 18 | i, err := NewURL(badURL) 19 | assert.Equal("invalid region, invalid format", err.Error(), "NewURL error message", t) 20 | assert.False(i.Valid(), "IIIF URL is invalid", t) 21 | 22 | // All other data should still be extracted despite this being a bad IIIF URL 23 | assert.Equal(weirdID, string(i.ID), "identifier should be extracted", t) 24 | assert.Equal(RTNone, i.Region.Type, "bad Region is RTNone", t) 25 | assert.Equal(STFull, i.Size.Type, "Size is STFull", t) 26 | assert.Equal(30.0, i.Rotation.Degrees, "i.Rotation.Degrees", t) 27 | assert.True(!i.Rotation.Mirror, "!i.Rotation.Mirror", t) 28 | assert.Equal(QDefault, i.Quality, "i.Quality == QDefault", t) 29 | assert.Equal(FmtUnknown, i.Format, "i.Format == FmtJPG", t) 30 | assert.Equal(false, i.Info, "not an info request", t) 31 | } 32 | 33 | func TestValid(t *testing.T) { 34 | i, err := NewURL(simplePath) 35 | assert.NilError(err, "NewURL has no error", t) 36 | 37 | assert.True(i.Valid(), fmt.Sprintf("Expected %s to be valid", simplePath), t) 38 | assert.Equal(weirdID, string(i.ID), "identifier should be extracted", t) 39 | assert.Equal(RTFull, i.Region.Type, "Region is RTFull", t) 40 | assert.Equal(STFull, i.Size.Type, "Size is STFull", t) 41 | assert.Equal(30.0, i.Rotation.Degrees, "i.Rotation.Degrees", t) 42 | assert.True(!i.Rotation.Mirror, "!i.Rotation.Mirror", t) 43 | assert.Equal(QDefault, i.Quality, "i.Quality == QDefault", t) 44 | assert.Equal(FmtJPG, i.Format, "i.Format == FmtJPG", t) 45 | assert.Equal(false, i.Info, "not an info request", t) 46 | } 47 | 48 | func TestInfo(t *testing.T) { 49 | i, err := NewURL("some%2Fvalid%2Fpath.jp2/info.json") 50 | assert.NilError(err, "info request isn't an error", t) 51 | assert.Equal("some/valid/path.jp2", string(i.ID), "identifier", t) 52 | assert.Equal(true, i.Info, "is an info request", t) 53 | } 54 | 55 | func TestInfoBaseRedirect(t *testing.T) { 56 | i, err := NewURL("some%2Fvalid%2Fpath.jp2") 57 | assert.Equal("empty id, invalid region, invalid size, invalid quality", err.Error(), "base redirects are error cases the caller must handle", t) 58 | assert.Equal("", string(i.ID), "identifier", t) 59 | } 60 | -------------------------------------------------------------------------------- /src/img/cloud_stream_test.go: -------------------------------------------------------------------------------- 1 | package img 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/url" 7 | "os" 8 | "path" 9 | "testing" 10 | 11 | "github.com/uoregon-libraries/gopkg/assert" 12 | ) 13 | 14 | func TestS3URL(t *testing.T) { 15 | assert.NilError(os.Setenv(EnvS3Endpoint, ""), "Setenv succeeded", t) 16 | assert.NilError(os.Setenv(EnvS3DisableSSL, ""), "Setenv succeeded", t) 17 | assert.NilError(os.Setenv(EnvS3ForcePathStyle, ""), "Setenv succeeded", t) 18 | 19 | var u, _ = url.Parse("s3://mybucket/path/to/asset.jp2") 20 | var s = new(CloudStream) 21 | var err = s.initialize(u) 22 | if err != nil { 23 | t.Errorf("Unable to initialize %#v: %s", u, err) 24 | } 25 | 26 | var expected = "s3://mybucket" 27 | if s.bucketURL != expected { 28 | t.Errorf("expected bucket to be %q, got %q", expected, s.bucketURL) 29 | } 30 | 31 | expected = "path/to/asset.jp2" 32 | if s.key != expected { 33 | t.Errorf("expected key to be %q, got %q", expected, s.key) 34 | } 35 | } 36 | 37 | func TestS3CustomURL(t *testing.T) { 38 | assert.NilError(os.Setenv(EnvS3Endpoint, "minio:9000"), "Setenv succeeded", t) 39 | assert.NilError(os.Setenv(EnvS3DisableSSL, "true"), "Setenv succeeded", t) 40 | assert.NilError(os.Setenv(EnvS3ForcePathStyle, "false"), "Setenv succeeded", t) 41 | 42 | var u, _ = url.Parse("s3://mybucket/path/to/asset.jp2") 43 | var s = new(CloudStream) 44 | var err = s.initialize(u) 45 | if err != nil { 46 | t.Errorf("Unable to initialize %#v: %s", u, err) 47 | } 48 | 49 | var expected = "s3://mybucket?endpoint=minio:9000&disableSSL=true" 50 | if s.bucketURL != expected { 51 | t.Errorf("expected bucket to be %q, got %q", expected, s.bucketURL) 52 | } 53 | } 54 | 55 | func openFile(testPath string) (realFile *os.File, cloudFile *CloudStream, info os.FileInfo) { 56 | var err error 57 | realFile, err = os.Open(testPath) 58 | if err != nil { 59 | panic(fmt.Sprintf("os.Open(%q) error: %s", testPath, err)) 60 | } 61 | 62 | info, _ = realFile.Stat() 63 | if err != nil { 64 | _ = realFile.Close() 65 | panic(fmt.Sprintf("realFile.Stat() error: %s", err)) 66 | } 67 | 68 | var u, _ = url.Parse("file://" + testPath) 69 | cloudFile, err = OpenStream(u) 70 | if err != nil { 71 | panic(fmt.Sprintf("OpenStream(%q) error: %s", "file://"+testPath, err)) 72 | } 73 | 74 | return realFile, cloudFile, info 75 | } 76 | 77 | func testRead(a, b io.Reader, bufsize int, t *testing.T) { 78 | var err error 79 | var aDat = make([]byte, bufsize) 80 | var bDat = make([]byte, bufsize) 81 | var aN, bN int 82 | 83 | aN, err = a.Read(aDat) 84 | if err != nil { 85 | panic(fmt.Sprintf("error reading a: %s", err)) 86 | } 87 | bN, err = b.Read(bDat) 88 | if err != nil { 89 | panic(fmt.Sprintf("error reading b: %s", err)) 90 | } 91 | if aN != bN { 92 | t.Errorf("a read %d; b read %d", aN, bN) 93 | } 94 | if string(aDat) != string(bDat) { 95 | t.Errorf("aDat %q didn't match bDat %q", aDat, bDat) 96 | } 97 | } 98 | 99 | func testSeek(a, b io.Seeker, offset int64, whence int, t *testing.T) { 100 | var err error 101 | var aN, bN int64 102 | aN, err = a.Seek(offset, whence) 103 | if err != nil { 104 | panic(fmt.Sprintf("error seeking a: %s", err)) 105 | } 106 | bN, err = b.Seek(offset, whence) 107 | if err != nil { 108 | panic(fmt.Sprintf("error seeking b: %s", err)) 109 | } 110 | 111 | if aN != bN { 112 | t.Errorf("seek(%d, %d) returns %d for a but %d for b", offset, whence, aN, bN) 113 | } 114 | } 115 | 116 | func TestRandomAccess(t *testing.T) { 117 | var dir, err = os.Getwd() 118 | if err != nil { 119 | t.Fatalf("os.Getwd() error: %s", err) 120 | } 121 | var testPath = path.Join(dir, "../../docker/images/jp2tests/sn00063609-19091231.jp2") 122 | 123 | var realFile, cloudFile, info = openFile(testPath) 124 | if info.Size() != cloudFile.Size() { 125 | t.Errorf("realFile size %d; cloudFile size %d", info.Size(), cloudFile.Size()) 126 | } 127 | 128 | testRead(realFile, cloudFile, 8192, t) 129 | testRead(realFile, cloudFile, 10240, t) 130 | testSeek(realFile, cloudFile, 50000, 0, t) 131 | testRead(realFile, cloudFile, 10240, t) 132 | testSeek(realFile, cloudFile, 50000, 1, t) 133 | testRead(realFile, cloudFile, 10240, t) 134 | } 135 | -------------------------------------------------------------------------------- /src/img/constraint.go: -------------------------------------------------------------------------------- 1 | package img 2 | 3 | // Constraint holds maximums the server is willing to return in image dimensions 4 | type Constraint struct { 5 | Width int 6 | Height int 7 | Area int64 8 | } 9 | 10 | // SmallerThanAny returns true if the constraint's maximums are exceeded by the 11 | // given width and height 12 | func (c Constraint) SmallerThanAny(w, h int) bool { 13 | return w > c.Width || h > c.Height || int64(w)*int64(h) > c.Area 14 | } 15 | -------------------------------------------------------------------------------- /src/img/decoder.go: -------------------------------------------------------------------------------- 1 | package img 2 | 3 | import ( 4 | "image" 5 | ) 6 | 7 | // Decoder defines an interface for reading images in a generic way. It's 8 | // heavily biased toward the way we've had to do our JP2 images since they're 9 | // the more unusual use-case. 10 | type Decoder interface { 11 | DecodeImage() (image.Image, error) 12 | GetWidth() int 13 | GetHeight() int 14 | GetTileWidth() int 15 | GetTileHeight() int 16 | GetLevels() int 17 | SetCrop(image.Rectangle) 18 | SetResizeWH(int, int) 19 | } 20 | 21 | // DecodeHandler is a function which takes a Streamer and returns a DecodeFunc and 22 | // optionally an error. If the error is ErrSkipped, the function is stating 23 | // that it doesn't handle images the Streamer describes (typically just a brief 24 | // check on the URL suffices, but a plugin could choose to read data from the 25 | // streamer to get, e.g., a proper mime type). A return with a nil error means 26 | // the returned function should be used and searching is done. 27 | type DecodeHandler func(Streamer) (DecodeFunc, error) 28 | 29 | // DecodeFunc is the actual function which must be called for decoding its info 30 | // / image data. Since this will typically read from the stream immediately, 31 | // it may return an error. A handler is expected to hold onto its Streamer so 32 | // its returned DecodeFunc doesn't have to take an unnecessary parameter. 33 | type DecodeFunc func() (Decoder, error) 34 | 35 | // decodeHandlers is our internal list of registered decoder functions 36 | var decodeHandlers []DecodeHandler 37 | 38 | // RegisterDecodeHandler adds a DecodeHandler to the internal list of 39 | // registered handlers. Images we want to decode will be run through each 40 | // function until one returns a handler and nil error. 41 | func RegisterDecodeHandler(fn DecodeHandler) { 42 | decodeHandlers = append(decodeHandlers, fn) 43 | } 44 | -------------------------------------------------------------------------------- /src/img/errors.go: -------------------------------------------------------------------------------- 1 | package img 2 | 3 | // imgError is just a glorified string so we can have error constants 4 | type imgError string 5 | 6 | func (re imgError) Error() string { 7 | return string(re) 8 | } 9 | 10 | // Custom errors an image read/transform operation could return 11 | const ( 12 | ErrDoesNotExist imgError = "image file does not exist" 13 | ErrInvalidFiletype imgError = "invalid or unknown file type" 14 | ErrDimensionsExceedLimits imgError = "requested image size exceeds server maximums" 15 | ErrNotStreamable imgError = "no registered streamers" 16 | ) 17 | -------------------------------------------------------------------------------- /src/img/file_stream.go: -------------------------------------------------------------------------------- 1 | package img 2 | 3 | import ( 4 | "net/url" 5 | "os" 6 | "time" 7 | ) 8 | 9 | // FileStream simply wraps an os.File to provide streaming functionality 10 | type FileStream struct { 11 | filepath string 12 | info os.FileInfo 13 | *os.File 14 | } 15 | 16 | // NewFileStream returns a streamer for the given URL's path. If the URL's 17 | // path doesn't refer to a file on the local filesystem, this will fail in 18 | // stupid ways. 19 | func NewFileStream(path string) (*FileStream, error) { 20 | var fs = &FileStream{filepath: path} 21 | var err error 22 | 23 | fs.info, err = os.Stat(path) 24 | 25 | // Make sure the most common error, at least, will get reported *our* way 26 | // (e.g., translated to a 404 when this is done via a web request) 27 | if os.IsNotExist(err) { 28 | return nil, ErrDoesNotExist 29 | } 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | fs.File, err = os.Open(path) 35 | return fs, err 36 | } 37 | 38 | // Location returns a "file://" location based on the original path 39 | func (fs *FileStream) Location() *url.URL { 40 | return &url.URL{Scheme: "file", Path: fs.filepath} 41 | } 42 | 43 | // Size returns the file's length in bytes 44 | func (fs *FileStream) Size() int64 { 45 | return fs.info.Size() 46 | } 47 | 48 | // ModTime returns when the file was last changed 49 | func (fs *FileStream) ModTime() time.Time { 50 | return fs.info.ModTime() 51 | } 52 | -------------------------------------------------------------------------------- /src/img/resource_test.go: -------------------------------------------------------------------------------- 1 | package img 2 | 3 | import ( 4 | "image" 5 | "math" 6 | "rais/src/iiif" 7 | "testing" 8 | 9 | "github.com/uoregon-libraries/gopkg/assert" 10 | ) 11 | 12 | var unlimited = Constraint{math.MaxInt32, math.MaxInt32, math.MaxInt64} 13 | 14 | type fakeDecoder struct { 15 | // Fake image dimensions and other metadata 16 | w, h int 17 | tw, th int 18 | l int 19 | 20 | // Settings touched by Apply 21 | crop image.Rectangle 22 | resizeW int 23 | resizeH int 24 | } 25 | 26 | func (d *fakeDecoder) DecodeImage() (image.Image, error) { return nil, nil } 27 | func (d *fakeDecoder) GetWidth() int { return d.w } 28 | func (d *fakeDecoder) GetHeight() int { return d.h } 29 | func (d *fakeDecoder) GetTileWidth() int { return d.tw } 30 | func (d *fakeDecoder) GetTileHeight() int { return d.th } 31 | func (d *fakeDecoder) GetLevels() int { return d.l } 32 | func (d *fakeDecoder) SetCrop(rect image.Rectangle) { d.crop = rect } 33 | func (d *fakeDecoder) SetResizeWH(w, h int) { d.resizeW, d.resizeH = w, h } 34 | 35 | func TestSquareRegionTall(t *testing.T) { 36 | var d = &fakeDecoder{w: 400, h: 950, tw: 64, th: 64, l: 1} 37 | var tall = &Resource{decoder: d} 38 | var url, _ = iiif.NewURL("identifier/square/full/0/default.jpg") 39 | var _, err = tall.Apply(url, unlimited) 40 | assert.True(err == nil, "tall.Apply should not have errors", t) 41 | 42 | assert.Equal(image.Point{400, 400}, d.crop.Size(), "square should be width x width", t) 43 | assert.Equal(0, d.crop.Min.X, "tall image left", t) 44 | assert.Equal(275, d.crop.Min.Y, "tall image top", t) 45 | assert.Equal(400, d.crop.Max.X, "tall image right", t) 46 | assert.Equal(675, d.crop.Max.Y, "tall image bottom", t) 47 | } 48 | 49 | // Now repeat it all but with a wide image; other changes just prove tile 50 | // sizes and levels don't matter here 51 | func TestSquareRegionWide(t *testing.T) { 52 | var d = &fakeDecoder{w: 4000, h: 650, tw: 128, th: 128, l: 4} 53 | var wide = &Resource{decoder: d} 54 | var url, _ = iiif.NewURL("identifier/square/full/0/default.jpg") 55 | var _, err = wide.Apply(url, unlimited) 56 | assert.True(err == nil, "wide.Apply should not have errors", t) 57 | 58 | assert.Equal(image.Point{650, 650}, d.crop.Size(), "square should be height x height", t) 59 | assert.Equal(1675, d.crop.Min.X, "wide image left", t) 60 | assert.Equal(0, d.crop.Min.Y, "wide image top", t) 61 | assert.Equal(2325, d.crop.Max.X, "wide image right", t) 62 | assert.Equal(650, d.crop.Max.Y, "wide image bottom", t) 63 | } 64 | 65 | func TestMaxSizeNoConstraints(t *testing.T) { 66 | var d = &fakeDecoder{w: 4000, h: 650, tw: 128, th: 128, l: 4} 67 | var img = &Resource{decoder: d} 68 | var url, _ = iiif.NewURL("identifier/full/max/0/default.jpg") 69 | var _, err = img.Apply(url, unlimited) 70 | assert.True(err == nil, "img.Apply should not have errors", t) 71 | 72 | assert.Equal(image.Point{4000, 650}, d.crop.Size(), "max size should be full width x height", t) 73 | assert.Equal(4000, d.resizeW, "resize width", t) 74 | assert.Equal(650, d.resizeH, "resize height", t) 75 | } 76 | 77 | func TestMaxSizeConstrainWidth(t *testing.T) { 78 | var d = &fakeDecoder{w: 4000, h: 650, tw: 128, th: 128, l: 4} 79 | var img = &Resource{decoder: d} 80 | var url, _ = iiif.NewURL("identifier/full/max/0/default.jpg") 81 | var c = unlimited 82 | c.Width = 400 83 | var _, err = img.Apply(url, c) 84 | assert.True(err == nil, "img.Apply should not have errors", t) 85 | 86 | assert.Equal(image.Point{4000, 650}, d.crop.Size(), "no crop", t) 87 | assert.Equal(400, d.resizeW, "resize width", t) 88 | assert.Equal(65, d.resizeH, "resize height", t) 89 | } 90 | 91 | func TestMaxSizeConstrainHeight(t *testing.T) { 92 | var d = &fakeDecoder{w: 4000, h: 650, tw: 128, th: 128, l: 4} 93 | var img = &Resource{decoder: d} 94 | var url, _ = iiif.NewURL("identifier/full/max/0/default.jpg") 95 | var c = unlimited 96 | c.Height = 325 97 | var _, err = img.Apply(url, c) 98 | assert.True(err == nil, "img.Apply should not have errors", t) 99 | 100 | assert.Equal(image.Point{4000, 650}, d.crop.Size(), "no crop", t) 101 | assert.Equal(2000, d.resizeW, "resize width", t) 102 | assert.Equal(325, d.resizeH, "resize height", t) 103 | } 104 | 105 | func TestMaxSizeConstrainArea(t *testing.T) { 106 | var d = &fakeDecoder{w: 4000, h: 600, tw: 128, th: 128, l: 4} 107 | var img = &Resource{decoder: d} 108 | var url, _ = iiif.NewURL("identifier/full/max/0/default.jpg") 109 | var c = unlimited 110 | c.Area = 37500 111 | var _, err = img.Apply(url, c) 112 | assert.True(err == nil, "img.Apply should not have errors", t) 113 | 114 | assert.Equal(image.Point{4000, 600}, d.crop.Size(), "no crop", t) 115 | assert.Equal(500, d.resizeW, "resize width", t) 116 | assert.Equal(75, d.resizeH, "resize height", t) 117 | } 118 | -------------------------------------------------------------------------------- /src/img/streamer.go: -------------------------------------------------------------------------------- 1 | package img 2 | 3 | import ( 4 | "io" 5 | "net/url" 6 | "time" 7 | ) 8 | 9 | // Streamer is an encapsulation of basic metadata checking, reading, seeking, 10 | // and closing so that we can implement image and info.json streaming from 11 | // memory, a file, S3, etc. 12 | type Streamer interface { 13 | Location() *url.URL // Location returns the URL to the object being streamed 14 | Size() int64 // Size in bytes of the stream data 15 | ModTime() time.Time // When the data was last modified 16 | io.ReadSeeker 17 | io.Closer 18 | } 19 | 20 | // StreamReader is a function which takes a URL and returns an OpenStreamFunc 21 | // and optionally an error. The error should generally be nil (success) or 22 | // ErrSkipped (the reader doesn't handle the given URL). The returned function 23 | // must be bound to the URL to avoid passing the URL around extra times, or 24 | // worse, passing the wrong URL into an OpenStreamFunc that won't be able to 25 | // handle it. 26 | type StreamReader func(*url.URL) (OpenStreamFunc, error) 27 | 28 | // OpenStreamFunc is the function which actually returns a Streamer (ready for 29 | // use) or else an error. 30 | type OpenStreamFunc func() (Streamer, error) 31 | 32 | // streamFuncs is our internal list of registered streamer functions 33 | var streamReaders []StreamReader 34 | 35 | // RegisterStreamReader adds a reader to the internal list of registered 36 | // readers. Image URLs will be run through each reader until one returns an 37 | // OpenStreamFunc and nil error. 38 | func RegisterStreamReader(fn StreamReader) { 39 | streamReaders = append(streamReaders, fn) 40 | } 41 | -------------------------------------------------------------------------------- /src/jp2info/info.go: -------------------------------------------------------------------------------- 1 | package jp2info 2 | 3 | // ColorMethod tells us how to determine the colorspace 4 | type ColorMethod uint8 5 | 6 | // Known color methods 7 | const ( 8 | CMEnumerated ColorMethod = 1 9 | CMRestrictedICC = 2 10 | ) 11 | 12 | // ColorSpace tells us how to parse color data coming from openjpeg 13 | type ColorSpace uint8 14 | 15 | // Known color spaces 16 | const ( 17 | CSUnknown ColorSpace = iota 18 | CSRGB 19 | CSGrayScale 20 | CSYCC 21 | ) 22 | 23 | // Info stores a variety of data we can easily scan from a jpeg2000 header 24 | type Info struct { 25 | // Main header info 26 | Width, Height uint32 27 | Comps uint16 28 | BPC uint8 29 | 30 | // Color data 31 | ColorMethod ColorMethod 32 | ColorSpace ColorSpace 33 | Prec, Approx uint8 34 | 35 | // From SIZ box - this data can replace the main header data and 36 | // some of the colorspace data if necessary 37 | LSiz, RSiz uint16 38 | XSiz, YSiz uint32 39 | XOSiz, YOSiz uint32 40 | XTSiz, YTSiz uint32 41 | XTOSiz, YTOSiz uint32 42 | CSiz uint16 43 | 44 | // From COD box 45 | LCod uint16 46 | SCod uint8 47 | SGCod uint32 48 | Levels uint8 49 | } 50 | 51 | // TileWidth computes width of tiles 52 | func (i *Info) TileWidth() uint32 { 53 | return i.XTSiz - i.XTOSiz 54 | } 55 | 56 | // TileHeight computes height of tiles 57 | func (i *Info) TileHeight() uint32 { 58 | return i.YTSiz - i.YTOSiz 59 | } 60 | 61 | // String reports the ColorSpace in a human-readable way 62 | func (cs ColorSpace) String() string { 63 | switch cs { 64 | case CSRGB: 65 | return "RGB" 66 | case CSGrayScale: 67 | return "Grayscale" 68 | case CSYCC: 69 | return "YCC" 70 | } 71 | return "Unknown" 72 | } 73 | -------------------------------------------------------------------------------- /src/jp2info/scanner.go: -------------------------------------------------------------------------------- 1 | package jp2info 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/binary" 7 | "fmt" 8 | "io" 9 | "os" 10 | ) 11 | 12 | // JP2HEADER contains the raw bytes for the only JP2 header we currently 13 | // respect 14 | var JP2HEADER = []byte{ 15 | 0x00, 0x00, 0x00, 0x0c, 16 | 0x6a, 0x50, 0x20, 0x20, 17 | 0x0d, 0x0a, 0x87, 0x0a, 18 | } 19 | 20 | // Various hard-coded byte values for finding JP2 boxes 21 | var ( 22 | IHDR = []byte{0x69, 0x68, 0x64, 0x72} // "ihdr" 23 | COLR = []byte{0x63, 0x6f, 0x6c, 0x72} // "colr" 24 | SOCSIZ = []byte{0xFF, 0x4F, 0xFF, 0x51} 25 | COD = []byte{0xFF, 0x52} 26 | ) 27 | 28 | // Scanner reads a Jpeg2000 header and parsing its data into an Info structure 29 | type Scanner struct { 30 | r *bufio.Reader 31 | e error 32 | i *Info 33 | } 34 | 35 | // Scan reads the file and populates an Info pointer 36 | func (s *Scanner) Scan(filename string) (*Info, error) { 37 | var f, err = os.Open(filename) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | s.readInfo(f) 43 | return s.i, s.e 44 | } 45 | 46 | // ScanStream reads JP2 info from an io.Reader to return JP2 structural data 47 | func (s *Scanner) ScanStream(r io.Reader) (*Info, error) { 48 | s.readInfo(r) 49 | return s.i, s.e 50 | } 51 | 52 | func (s *Scanner) readInfo(ior io.Reader) { 53 | s.i = &Info{} 54 | s.r = bufio.NewReader(ior) 55 | 56 | // Make sure the header bytes are legit - this doesn't cover all types of 57 | // JP2, but it works for what RAIS needs 58 | var header = make([]byte, 12) 59 | _, s.e = s.r.Read(header) 60 | if !bytes.Equal(header, JP2HEADER) { 61 | s.e = fmt.Errorf("unknown file format") 62 | return 63 | } 64 | 65 | // Find IHDR for basic information 66 | s.scanUntil(IHDR) 67 | s.readBE(&s.i.Height, &s.i.Width, &s.i.Comps, &s.i.BPC) 68 | 69 | // For some reason this is always 7 or 15 70 | s.i.BPC++ 71 | 72 | // Find COLR to get colorspace data 73 | s.scanUntil(COLR) 74 | s.readColor() 75 | 76 | // Find various SIZ data 77 | s.scanUntil(SOCSIZ) 78 | s.readBE(&s.i.LSiz, &s.i.RSiz, &s.i.XSiz, &s.i.YSiz, &s.i.XOSiz, 79 | &s.i.YOSiz, &s.i.XTSiz, &s.i.YTSiz, &s.i.XTOSiz, &s.i.YTOSiz, &s.i.CSiz) 80 | 81 | // Find COD, primarily to get resolution levels 82 | s.scanUntil(COD) 83 | s.readBE(&s.i.LCod, &s.i.SCod, &s.i.SGCod, &s.i.Levels) 84 | } 85 | 86 | func (s *Scanner) readColor() { 87 | s.readBE(&s.i.ColorMethod, &s.i.Prec, &s.i.Approx) 88 | if s.i.ColorMethod == CMEnumerated { 89 | s.readEnumeratedColor() 90 | } else { 91 | s.readColorProfile() 92 | } 93 | } 94 | 95 | func (s *Scanner) readEnumeratedColor() { 96 | var _, err = s.r.Discard(2) 97 | if err != nil { 98 | s.e = err 99 | return 100 | } 101 | var colorSpace uint16 102 | 103 | s.readBE(&colorSpace) 104 | switch colorSpace { 105 | case 16: 106 | s.i.ColorSpace = CSRGB 107 | case 17: 108 | s.i.ColorSpace = CSGrayScale 109 | case 18: 110 | s.i.ColorSpace = CSYCC 111 | default: 112 | s.i.ColorSpace = CSUnknown 113 | } 114 | } 115 | 116 | func (s *Scanner) readColorProfile() { 117 | // TODO: make this a bit more useful 118 | s.i.ColorSpace = CSUnknown 119 | } 120 | 121 | // scanUntil reads until the given token has been found and fully read 122 | // in, leaving the io pointer exactly one byte past the token 123 | func (s *Scanner) scanUntil(token []byte) { 124 | if s.e != nil { 125 | return 126 | } 127 | 128 | var matchOffset int 129 | var tokenLen = len(token) 130 | var b byte 131 | 132 | for { 133 | b, s.e = s.r.ReadByte() 134 | if s.e != nil { 135 | return 136 | } 137 | 138 | if b == token[matchOffset] { 139 | matchOffset++ 140 | } 141 | 142 | if matchOffset == tokenLen { 143 | return 144 | } 145 | } 146 | } 147 | 148 | // readBE wraps binary.Read for reading any arbitrary amount of BigEndian data 149 | func (s *Scanner) readBE(data ...any) { 150 | if s.e != nil { 151 | return 152 | } 153 | 154 | var datum any 155 | for _, datum = range data { 156 | s.e = binary.Read(s.r, binary.BigEndian, datum) 157 | if s.e != nil { 158 | return 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/openjpeg/decode.go: -------------------------------------------------------------------------------- 1 | package openjpeg 2 | 3 | // #cgo pkg-config: libopenjp2 4 | // #include 5 | import "C" 6 | import ( 7 | "errors" 8 | "image" 9 | "reflect" 10 | "unsafe" 11 | ) 12 | 13 | type opjp2 struct { 14 | comps []C.opj_image_comp_t 15 | width int 16 | height int 17 | bounds image.Rectangle 18 | bpc uint8 19 | } 20 | 21 | func newOpjp2(comps []C.opj_image_comp_t, bpc uint8) (*opjp2, error) { 22 | if bpc != 8 && bpc != 16 { 23 | return nil, errors.New("bit depth must be 8 or 16") 24 | } 25 | 26 | var j = &opjp2{comps: comps} 27 | j.width = int(comps[0].w) 28 | j.height = int(comps[0].h) 29 | j.bounds = image.Rect(0, 0, j.width, j.height) 30 | j.bpc = bpc 31 | 32 | return j, nil 33 | } 34 | 35 | func (j *opjp2) decode() (image.Image, error) { 36 | var gray = len(j.comps) < 3 37 | var i image.Image 38 | switch { 39 | case j.bpc == 8 && gray: 40 | i = j.decodeGray8() 41 | case j.bpc == 8 && !gray: 42 | i = j.decodeRGB8() 43 | case j.bpc == 16 && gray: 44 | i = j.decodeGray16() 45 | case j.bpc == 16 && !gray: 46 | i = j.decodeRGB16() 47 | } 48 | 49 | return i, nil 50 | } 51 | 52 | func (j *opjp2) decodeGray8() image.Image { 53 | return &image.Gray{Pix: jp2ComponentData8(j.comps[0]), Stride: j.width, Rect: j.bounds} 54 | } 55 | 56 | func (j *opjp2) decodeGray16() image.Image { 57 | return &image.Gray16{Pix: jp2ComponentData16(j.comps[0]), Stride: j.width << 1, Rect: j.bounds} 58 | } 59 | 60 | func (j *opjp2) decodeRGB8() image.Image { 61 | var area = j.width * j.height 62 | var bytes = area << 2 63 | var realData = make([]uint8, bytes) 64 | 65 | var red = jp2ComponentData8(j.comps[0]) 66 | var green = jp2ComponentData8(j.comps[1]) 67 | var blue = jp2ComponentData8(j.comps[2]) 68 | 69 | var offset = 0 70 | for i := 0; i < area; i++ { 71 | realData[offset] = red[i] 72 | offset++ 73 | realData[offset] = green[i] 74 | offset++ 75 | realData[offset] = blue[i] 76 | offset++ 77 | realData[offset] = 255 78 | offset++ 79 | } 80 | 81 | return &image.RGBA{Pix: realData, Stride: j.width << 2, Rect: j.bounds} 82 | } 83 | 84 | func (j *opjp2) decodeRGB16() image.Image { 85 | var red = jp2ComponentData16(j.comps[0]) 86 | var green = jp2ComponentData16(j.comps[1]) 87 | var blue = jp2ComponentData16(j.comps[2]) 88 | 89 | var offset = 0 90 | var pixels = j.width*j.height 91 | var realData = make([]uint8, j.width*j.height*8) 92 | for i := 0; i < pixels; i++ { 93 | offset = i << 3 94 | realData[offset] = red[i<<1] 95 | realData[offset+1] = red[i<<1+1] 96 | realData[offset+2] = green[i<<1] 97 | realData[offset+3] = green[i<<1+1] 98 | realData[offset+4] = blue[i<<1] 99 | realData[offset+5] = blue[i<<1+1] 100 | realData[offset+6] = 0xFF 101 | realData[offset+7] = 0xFF 102 | } 103 | 104 | return &image.RGBA64{Pix: realData, Stride: j.width << 3, Rect: j.bounds} 105 | } 106 | 107 | // jp2ComponentData8 returns a slice of Image-usable uint8s from the JP2 raw 108 | // data in the given openjpeg component 109 | func jp2ComponentData8(comp C.struct_opj_image_comp) []uint8 { 110 | var data []int32 111 | dataSlice := (*reflect.SliceHeader)((unsafe.Pointer(&data))) 112 | size := int(comp.w) * int(comp.h) 113 | dataSlice.Cap = size 114 | dataSlice.Len = size 115 | dataSlice.Data = uintptr(unsafe.Pointer(comp.data)) 116 | 117 | realData := make([]uint8, len(data)) 118 | for index, point := range data { 119 | realData[index] = uint8(point) 120 | } 121 | 122 | return realData 123 | } 124 | 125 | // jp2ComponentData16 returns a slice of Image-usable uint8s for a 16-bit 126 | // grayscale image, using the JP2 raw data in the given openjpeg component 127 | func jp2ComponentData16(comp C.struct_opj_image_comp) []uint8 { 128 | var data []int32 129 | dataSlice := (*reflect.SliceHeader)((unsafe.Pointer(&data))) 130 | size := int(comp.w) * int(comp.h) 131 | dataSlice.Cap = size 132 | dataSlice.Len = size 133 | dataSlice.Data = uintptr(unsafe.Pointer(comp.data)) 134 | 135 | realData := make([]uint8, len(data)*2) 136 | for i, point := range data { 137 | var val = uint16(point) 138 | realData[i<<1+0] = uint8(val >> 8) 139 | realData[i<<1+1] = uint8(val) 140 | } 141 | 142 | return realData 143 | } 144 | -------------------------------------------------------------------------------- /src/openjpeg/handlers.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include "handlers.h" 4 | #include "_cgo_export.h" 5 | 6 | static void warning_callback(const char *msg, void *client_data) { 7 | GoLogWarning((char *)msg); 8 | } 9 | 10 | static void error_callback(const char *msg, void *client_data) { 11 | GoLogError((char *)msg); 12 | } 13 | 14 | void set_handlers(opj_codec_t* p_codec) { 15 | opj_set_warning_handler(p_codec, warning_callback, 00); 16 | opj_set_error_handler(p_codec, error_callback, 00); 17 | } 18 | -------------------------------------------------------------------------------- /src/openjpeg/handlers.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | extern void set_handlers(opj_codec_t * p_codec); 5 | extern void GoLog(int level, char *message); 6 | -------------------------------------------------------------------------------- /src/openjpeg/image_stream.go: -------------------------------------------------------------------------------- 1 | package openjpeg 2 | 3 | // #cgo pkg-config: libopenjp2 4 | // #include 5 | import "C" 6 | import ( 7 | "io" 8 | "reflect" 9 | "sync" 10 | "unsafe" 11 | ) 12 | 13 | // These vars suck, but we have to have some way to find our objects when the 14 | // openjpeg C callbacks tell us to read/seek/etc. Trying to persist a pointer 15 | // between Go and C is definitely more dangerous than this hack.... 16 | var nextStreamID uint64 17 | var images = make(map[uint64]*JP2Image) 18 | var imageMutex sync.RWMutex 19 | 20 | // These are stupid, but we need to return what openjpeg considers failure 21 | // numbers, and Go doesn't allow a direct translation of negative values to an 22 | // unsigned type 23 | var opjZeroSizeT C.OPJ_SIZE_T = 0 24 | var opjMinusOneSizeT = opjZeroSizeT - 1 25 | 26 | // storeImage stores the next sequence id on the JP2 image and indexes it in 27 | // our image lookup map so opj streaming functions can find it 28 | func storeImage(i *JP2Image) { 29 | imageMutex.Lock() 30 | nextStreamID++ 31 | i.id = nextStreamID 32 | images[i.id] = i 33 | imageMutex.Unlock() 34 | } 35 | 36 | func lookupImage(id uint64) (*JP2Image, bool) { 37 | imageMutex.Lock() 38 | var i, ok = images[id] 39 | imageMutex.Unlock() 40 | 41 | return i, ok 42 | } 43 | 44 | //export freeStream 45 | func freeStream(id uint64) { 46 | imageMutex.Lock() 47 | delete(images, id) 48 | imageMutex.Unlock() 49 | } 50 | 51 | //export opjStreamRead 52 | func opjStreamRead(writeBuffer unsafe.Pointer, numBytes C.OPJ_SIZE_T, id uint64) C.OPJ_SIZE_T { 53 | var i, ok = lookupImage(id) 54 | if !ok { 55 | Logger.Errorf("Unable to find stream %d", id) 56 | return opjMinusOneSizeT 57 | } 58 | 59 | var data []byte 60 | var dataSlice = (*reflect.SliceHeader)(unsafe.Pointer(&data)) 61 | dataSlice.Cap = int(numBytes) 62 | dataSlice.Len = int(numBytes) 63 | dataSlice.Data = uintptr(unsafe.Pointer(writeBuffer)) 64 | 65 | var n, err = i.streamer.Read(data) 66 | 67 | // Dumb hack - gocloud (maybe others?) returns EOF differently for local file 68 | // read vs. an S3 read, and openjpeg doesn't have a way to be told "EOF and 69 | // data", so we ignore EOFs if any data was read from the stream 70 | if err == io.EOF && n > 0 { 71 | err = nil 72 | } 73 | 74 | if err != nil { 75 | if err != io.EOF { 76 | Logger.Errorf("Unable to read from stream %d: %s", id, err) 77 | } 78 | return opjMinusOneSizeT 79 | } 80 | 81 | return C.OPJ_SIZE_T(n) 82 | } 83 | 84 | //export opjStreamSkip 85 | // 86 | // opjStreamSkip jumps numBytes ahead in the stream, discarding any data that would be read 87 | func opjStreamSkip(numBytes C.OPJ_OFF_T, id uint64) C.OPJ_SIZE_T { 88 | var i, ok = lookupImage(id) 89 | if !ok { 90 | Logger.Errorf("Unable to find stream ID %d", id) 91 | return opjMinusOneSizeT 92 | } 93 | var _, err = i.streamer.Seek(int64(numBytes), io.SeekCurrent) 94 | if err != nil { 95 | Logger.Errorf("Unable to seek %d bytes forward: %s", numBytes, err) 96 | return opjMinusOneSizeT 97 | } 98 | 99 | // For some reason, success here seems to be a return value of the number of bytes passed in 100 | return C.OPJ_SIZE_T(numBytes) 101 | } 102 | 103 | //export opjStreamSeek 104 | // 105 | // opjStreamSeek jumps to the absolute position offset in the stream 106 | func opjStreamSeek(offset C.OPJ_OFF_T, id uint64) C.OPJ_BOOL { 107 | var i, ok = lookupImage(id) 108 | if !ok { 109 | Logger.Errorf("Unable to find stream ID %d", id) 110 | return C.OPJ_FALSE 111 | } 112 | var _, err = i.streamer.Seek(int64(offset), io.SeekStart) 113 | if err != nil { 114 | Logger.Errorf("Unable to seek to offset %d: %s", offset, err) 115 | return C.OPJ_FALSE 116 | } 117 | 118 | return C.OPJ_TRUE 119 | } 120 | -------------------------------------------------------------------------------- /src/openjpeg/jp2_image.go: -------------------------------------------------------------------------------- 1 | package openjpeg 2 | 3 | // #cgo pkg-config: libopenjp2 4 | // #include 5 | import "C" 6 | 7 | import ( 8 | "fmt" 9 | "image" 10 | "image/color" 11 | "rais/src/img" 12 | "rais/src/jp2info" 13 | "reflect" 14 | "unsafe" 15 | 16 | "golang.org/x/image/draw" 17 | ) 18 | 19 | // JP2Image is a container for our simple JP2 operations 20 | type JP2Image struct { 21 | id uint64 22 | streamer img.Streamer 23 | info *jp2info.Info 24 | decodeWidth int 25 | decodeHeight int 26 | decodeArea image.Rectangle 27 | srcRect image.Rectangle 28 | } 29 | 30 | // NewJP2Image reads basic information about a file and returns a decode-ready 31 | // JP2Image instance 32 | func NewJP2Image(s img.Streamer) (*JP2Image, error) { 33 | s.Seek(0, 0) 34 | var info, err = new(jp2info.Scanner).ScanStream(s) 35 | if err != nil { 36 | s.Close() 37 | return nil, err 38 | } 39 | 40 | var i = &JP2Image{streamer: s, info: info} 41 | storeImage(i) 42 | 43 | return i, err 44 | } 45 | 46 | // SetResizeWH sets the image to scale to the given width and height. If one 47 | // dimension is 0, the decoded image will preserve the aspect ratio while 48 | // scaling to the non-zero dimension. 49 | func (i *JP2Image) SetResizeWH(width, height int) { 50 | i.decodeWidth = width 51 | i.decodeHeight = height 52 | } 53 | 54 | // SetCrop sets the image crop area for decoding an image 55 | func (i *JP2Image) SetCrop(r image.Rectangle) { 56 | i.decodeArea = r 57 | } 58 | 59 | // DecodeImage returns an image.Image that holds the decoded image data, 60 | // resized and cropped if resizing or cropping was requested. Both cropping 61 | // and resizing happen here due to the nature of openjpeg, so SetScale, 62 | // SetResizeWH, and SetCrop must be called before this function. 63 | func (i *JP2Image) DecodeImage() (im image.Image, err error) { 64 | i.computeDecodeParameters() 65 | 66 | var jp2 *C.opj_image_t 67 | jp2, err = i.rawDecode() 68 | // We have to clean up the jp2 memory even if we had an error due to how the 69 | // openjpeg APIs work 70 | defer C.opj_image_destroy(jp2) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | var comps []C.opj_image_comp_t 76 | var compsSlice = (*reflect.SliceHeader)((unsafe.Pointer(&comps))) 77 | compsSlice.Cap = int(jp2.numcomps) 78 | compsSlice.Len = int(jp2.numcomps) 79 | compsSlice.Data = uintptr(unsafe.Pointer(jp2.comps)) 80 | 81 | var j *opjp2 82 | j, err = newOpjp2(comps, i.info.BPC) 83 | if err != nil { 84 | return nil, err 85 | } 86 | var decoded image.Image 87 | decoded, err = j.decode() 88 | if err != nil { 89 | return nil, fmt.Errorf("decoding raw JP2 data: %w", err) 90 | } 91 | 92 | if i.decodeWidth != i.decodeArea.Dx() || i.decodeHeight != i.decodeArea.Dy() { 93 | var resized draw.Image 94 | var rect = image.Rect(0, 0, i.decodeWidth, i.decodeHeight) 95 | 96 | switch decoded.ColorModel() { 97 | case color.RGBAModel: 98 | resized = image.NewRGBA(rect) 99 | case color.GrayModel: 100 | resized = image.NewGray(rect) 101 | case color.RGBA64Model: 102 | resized = image.NewRGBA64(rect) 103 | case color.Gray16Model: 104 | resized = image.NewGray16(rect) 105 | default: 106 | return nil, fmt.Errorf("unsupported color model") 107 | } 108 | draw.BiLinear.Scale(resized, rect, decoded, decoded.Bounds(), draw.Over, nil) 109 | decoded = resized 110 | } 111 | 112 | return decoded, nil 113 | } 114 | 115 | // GetWidth returns the image width 116 | func (i *JP2Image) GetWidth() int { 117 | return int(i.info.Width) 118 | } 119 | 120 | // GetHeight returns the image height 121 | func (i *JP2Image) GetHeight() int { 122 | return int(i.info.Height) 123 | } 124 | 125 | // GetTileWidth returns the tile width 126 | func (i *JP2Image) GetTileWidth() int { 127 | return int(i.info.TileWidth()) 128 | } 129 | 130 | // GetTileHeight returns the tile height 131 | func (i *JP2Image) GetTileHeight() int { 132 | return int(i.info.TileHeight()) 133 | } 134 | 135 | // GetLevels returns the number of resolution levels 136 | func (i *JP2Image) GetLevels() int { 137 | return int(i.info.Levels) 138 | } 139 | 140 | // computeDecodeParameters sets up decode area, decode width, and decode height 141 | // based on the image's info 142 | func (i *JP2Image) computeDecodeParameters() { 143 | if i.decodeArea == image.ZR { 144 | i.decodeArea = image.Rect(0, 0, int(i.info.Width), int(i.info.Height)) 145 | } 146 | 147 | if i.decodeWidth == 0 && i.decodeHeight == 0 { 148 | i.decodeWidth = i.decodeArea.Dx() 149 | i.decodeHeight = i.decodeArea.Dy() 150 | } 151 | } 152 | 153 | // computeProgressionLevel gets progression level if we're resizing to specific 154 | // dimensions (it's zero if there isn't any scaling of the output) 155 | func (i *JP2Image) computeProgressionLevel() int { 156 | if i.decodeWidth == i.decodeArea.Dx() && i.decodeHeight == i.decodeArea.Dy() { 157 | return 0 158 | } 159 | 160 | level := desiredProgressionLevel(i.decodeArea, i.decodeWidth, i.decodeHeight) 161 | if level > i.GetLevels() { 162 | Logger.Debugf("Progression level requested (%d) is too high", level) 163 | level = i.GetLevels() 164 | } 165 | 166 | return level 167 | } 168 | -------------------------------------------------------------------------------- /src/openjpeg/jp2_image_test.go: -------------------------------------------------------------------------------- 1 | package openjpeg 2 | 3 | import ( 4 | "image" 5 | "os" 6 | "rais/src/img" 7 | "testing" 8 | 9 | "github.com/uoregon-libraries/gopkg/assert" 10 | "github.com/uoregon-libraries/gopkg/logger" 11 | ) 12 | 13 | func init() { 14 | Logger = logger.New(logger.Warn) 15 | } 16 | 17 | func jp2i() *JP2Image { 18 | var dir, _ = os.Getwd() 19 | var s, err = img.NewFileStream(dir + "/../../docker/images/testfile/test-world.jp2") 20 | var jp2 *JP2Image 21 | if err == nil { 22 | jp2, err = NewJP2Image(s) 23 | } 24 | if err != nil { 25 | panic("Error reading JP2 for testing!") 26 | } 27 | return jp2 28 | } 29 | 30 | func TestNewJP2Image(t *testing.T) { 31 | jp2 := jp2i() 32 | 33 | if jp2 == nil { 34 | t.Error("No JP2 object!") 35 | } 36 | } 37 | 38 | func TestDimensions(t *testing.T) { 39 | jp2 := jp2i() 40 | assert.Equal(800, jp2.GetWidth(), "jp2 width is 800px", t) 41 | assert.Equal(400, jp2.GetHeight(), "jp2 height is 400px", t) 42 | } 43 | 44 | func TestDirectConversion(t *testing.T) { 45 | jp2 := jp2i() 46 | i, err := jp2.DecodeImage() 47 | assert.Equal(err, nil, "No error decoding jp2", t) 48 | assert.Equal(0, i.Bounds().Min.X, "Min.X should be 0", t) 49 | assert.Equal(0, i.Bounds().Min.Y, "Min.Y should be 0", t) 50 | assert.Equal(800, i.Bounds().Max.X, "Max.X should be 800", t) 51 | assert.Equal(400, i.Bounds().Max.Y, "Max.Y should be 400", t) 52 | } 53 | 54 | func TestCrop(t *testing.T) { 55 | jp2 := jp2i() 56 | jp2.SetCrop(image.Rect(200, 100, 500, 400)) 57 | i, err := jp2.DecodeImage() 58 | assert.Equal(err, nil, "No error decoding jp2", t) 59 | assert.Equal(0, i.Bounds().Min.X, "Min.X should be 0", t) 60 | assert.Equal(0, i.Bounds().Min.Y, "Min.Y should be 0", t) 61 | assert.Equal(300, i.Bounds().Max.X, "Max.X should be 300 (cropped X from 200 - 500)", t) 62 | assert.Equal(300, i.Bounds().Max.Y, "Max.Y should be 300 (cropped Y from 100 - 400)", t) 63 | } 64 | 65 | // This serves as a resize test as well as a test that we properly check 66 | // maximum resolution factor 67 | func TestResizeWH(t *testing.T) { 68 | jp2 := jp2i() 69 | jp2.SetResizeWH(50, 50) 70 | i, err := jp2.DecodeImage() 71 | assert.Equal(err, nil, "No error decoding jp2", t) 72 | assert.Equal(0, i.Bounds().Min.X, "Min.X should be 0", t) 73 | assert.Equal(0, i.Bounds().Min.Y, "Min.Y should be 0", t) 74 | assert.Equal(50, i.Bounds().Max.X, "Max.X should be 50", t) 75 | assert.Equal(50, i.Bounds().Max.Y, "Max.Y should be 50", t) 76 | } 77 | 78 | func TestResizeWHAndCrop(t *testing.T) { 79 | jp2 := jp2i() 80 | jp2.SetCrop(image.Rect(200, 100, 500, 400)) 81 | jp2.SetResizeWH(125, 125) 82 | i, err := jp2.DecodeImage() 83 | assert.Equal(err, nil, "No error decoding jp2", t) 84 | assert.Equal(0, i.Bounds().Min.X, "Min.X should be 0", t) 85 | assert.Equal(0, i.Bounds().Min.Y, "Min.Y should be 0", t) 86 | assert.Equal(125, i.Bounds().Max.X, "Max.X should be 125", t) 87 | assert.Equal(125, i.Bounds().Max.Y, "Max.Y should be 125", t) 88 | } 89 | 90 | // BenchmarkReadAndDecodeImage does a benchmark against every step of the 91 | // process to simulate the parts of a tile request controlled by the openjpeg 92 | // package: loading the JP2, setting the crop and resize, and decoding to a raw 93 | // image resource. The test image has no tiling, so this is benchmarking the 94 | // most expensive operation we currently have. 95 | func BenchmarkReadAndDecodeImage(b *testing.B) { 96 | for n := 0; n < b.N; n++ { 97 | startx := n % 512 98 | endx := startx + 256 99 | jp2 := jp2i() 100 | jp2.SetCrop(image.Rect(startx, 0, endx, 256)) 101 | jp2.SetResizeWH(128, 128) 102 | _, err := jp2.DecodeImage() 103 | if err != nil { 104 | panic(err) 105 | } 106 | } 107 | } 108 | 109 | // BenchmarkReadAndDecodeImage does a benchmark against a large, tiled image to 110 | // see how we perform when using the best-case image type 111 | func BenchmarkReadAndDecodeTiledImage(b *testing.B) { 112 | dir, _ := os.Getwd() 113 | bigImage, err := img.NewFileStream(dir + "/../../docker/images/jp2tests/sn00063609-19091231.jp2") 114 | if err != nil { 115 | b.Fatalf("Unable to open file stream for newspaper image: %s", err) 116 | } 117 | 118 | for n := 0; n < b.N; n++ { 119 | var size = ((n % 2) + 1) * 1024 120 | startTileX := n % 2 121 | startTileY := (n / 2) % 3 122 | startX := startTileX * size 123 | endX := startX + size 124 | startY := startTileY * size 125 | endY := startY + size 126 | 127 | jp2, err := NewJP2Image(bigImage) 128 | if err != nil { 129 | panic(err) 130 | } 131 | jp2.SetCrop(image.Rect(startX, startY, endX, endY)) 132 | jp2.SetResizeWH(1024, 1024) 133 | if _, err := jp2.DecodeImage(); err != nil { 134 | panic(err) 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/openjpeg/jp2_resources.go: -------------------------------------------------------------------------------- 1 | package openjpeg 2 | 3 | // #cgo pkg-config: libopenjp2 4 | // #include 5 | // #include 6 | // #include "handlers.h" 7 | // #include "stream.h" 8 | import "C" 9 | 10 | import ( 11 | "fmt" 12 | ) 13 | 14 | // rawDecode runs the low-level operations necessary to actually get the 15 | // desired tile/resized image 16 | func (i *JP2Image) rawDecode() (jp2 *C.opj_image_t, err error) { 17 | // Make sure we're at the beginning of the stream 18 | i.streamer.Seek(0, 0) 19 | 20 | // Setup the parameters for decode 21 | var parameters C.opj_dparameters_t 22 | C.opj_set_default_decoder_parameters(¶meters) 23 | 24 | // Calculate cp_reduce - this seems smarter to put in a parameter than to call an extra function 25 | parameters.cp_reduce = C.OPJ_UINT32(i.computeProgressionLevel()) 26 | 27 | // Setup the stream for openjpeg 28 | stream, err := i.initializeStream() 29 | if err != nil { 30 | return jp2, err 31 | } 32 | defer C.opj_stream_destroy(stream) 33 | 34 | // Create codec 35 | codec := C.opj_create_decompress(C.OPJ_CODEC_JP2) 36 | defer C.opj_destroy_codec(codec) 37 | 38 | // Connect our info/warning/error handlers 39 | C.set_handlers(codec) 40 | 41 | // Fill in codec configuration from parameters 42 | if C.opj_setup_decoder(codec, ¶meters) == C.OPJ_FALSE { 43 | return jp2, fmt.Errorf("unable to setup decoder") 44 | } 45 | 46 | // Read the header to set up the image data 47 | if C.opj_read_header(stream, codec, &jp2) == C.OPJ_FALSE { 48 | return jp2, fmt.Errorf("failed to read the header") 49 | } 50 | 51 | // Set the decode area if it isn't the full image 52 | if i.decodeArea != i.srcRect { 53 | r := i.decodeArea 54 | if C.opj_set_decode_area(codec, jp2, C.OPJ_INT32(r.Min.X), C.OPJ_INT32(r.Min.Y), C.OPJ_INT32(r.Max.X), C.OPJ_INT32(r.Max.Y)) == C.OPJ_FALSE { 55 | return jp2, fmt.Errorf("failed to set the decoded area") 56 | } 57 | } 58 | 59 | // Decode the JP2 into the image stream 60 | if C.opj_decode(codec, stream, jp2) == C.OPJ_FALSE || C.opj_end_decompress(codec, stream) == C.OPJ_FALSE { 61 | return jp2, fmt.Errorf("failed to decode image") 62 | } 63 | 64 | return jp2, nil 65 | } 66 | 67 | func (i *JP2Image) initializeStream() (*C.opj_stream_t, error) { 68 | var stream = C.new_stream(C.OPJ_UINT64(1024*10), C.OPJ_UINT64(i.id), C.OPJ_UINT64(i.streamer.Size())) 69 | if stream == nil { 70 | return nil, fmt.Errorf("failed to create stream for %q", i.streamer.Location()) 71 | } 72 | return stream, nil 73 | } 74 | -------------------------------------------------------------------------------- /src/openjpeg/logging.go: -------------------------------------------------------------------------------- 1 | package openjpeg 2 | 3 | // #cgo pkg-config: libopenjp2 4 | // #include "handlers.h" 5 | import "C" 6 | 7 | import ( 8 | "strings" 9 | 10 | "github.com/uoregon-libraries/gopkg/logger" 11 | ) 12 | 13 | // Logger defaults to use a default implementation of the uoregon-libraries 14 | // logging mechanism, but can be overridden (as is the case with the main RAIS 15 | // command) 16 | var Logger = logger.Named("rais/openjpeg", logger.Debug) 17 | 18 | // GoLogWarning bridges the openjpeg logging with our internal logger 19 | //export GoLogWarning 20 | func GoLogWarning(cmessage *C.char) { 21 | log(Logger.Warnf, cmessage) 22 | } 23 | 24 | // GoLogError bridges the openjpeg logging with our internal logger 25 | //export GoLogError 26 | func GoLogError(cmessage *C.char) { 27 | log(Logger.Errorf, cmessage) 28 | } 29 | 30 | // Internal go-specific version of logger 31 | func log(logfn func(string, ...any), cmessage *C.char) { 32 | var message = strings.TrimSpace(C.GoString(cmessage)) 33 | logfn("FROM OPJ: %s", message) 34 | } 35 | -------------------------------------------------------------------------------- /src/openjpeg/progression_level.go: -------------------------------------------------------------------------------- 1 | package openjpeg 2 | 3 | import ( 4 | "image" 5 | "math" 6 | ) 7 | 8 | // MaxProgressionLevel represents the maximum resolution factor for a JP2 9 | const MaxProgressionLevel = 32 10 | 11 | func min(a, b int) int { 12 | if a < b { 13 | return a 14 | } 15 | return b 16 | } 17 | 18 | // Returns the scale in powers of two between two numbers 19 | func getScale(v1, v2 int) int { 20 | if v1 == v2 { 21 | return 0 22 | } 23 | 24 | large, small := float64(v1), float64(v2) 25 | if large < small { 26 | large, small = small, large 27 | } 28 | 29 | return int(math.Floor(math.Log2(large) - math.Log2(small))) 30 | } 31 | 32 | func desiredProgressionLevel(r image.Rectangle, width, height int) int { 33 | if width > r.Dx() || height > r.Dy() { 34 | return 0 35 | } 36 | 37 | // If either dimension is zero, we want to avoid computation and just use the 38 | // other's scale value 39 | scaleX := MaxProgressionLevel 40 | scaleY := MaxProgressionLevel 41 | 42 | if width > 0 { 43 | scaleX = getScale(r.Dx(), width) 44 | } 45 | 46 | if height > 0 { 47 | scaleY = getScale(r.Dy(), height) 48 | } 49 | 50 | // Pull the smallest value - if we request a resize from 1000x1000 to 250x500 51 | // (for some odd reason), then we need to start with the 500x500 level, not 52 | // the 250x250 level 53 | level := min(scaleX, scaleY) 54 | return min(MaxProgressionLevel, level) 55 | } 56 | -------------------------------------------------------------------------------- /src/openjpeg/progression_level_test.go: -------------------------------------------------------------------------------- 1 | package openjpeg 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | 7 | "github.com/uoregon-libraries/gopkg/assert" 8 | ) 9 | 10 | func TestDesiredProgressionLevel(t *testing.T) { 11 | source := image.Rect(0, 0, 5000, 5000) 12 | dpl := func(w, h int) int { 13 | return desiredProgressionLevel(source, w, h) 14 | } 15 | 16 | assert.Equal(0, dpl(5000, 5000), "Source and dest are equal, level should be 0", t) 17 | assert.Equal(0, dpl(5001, 5001), "Source is SMALLER than dest, so level has to be 0", t) 18 | assert.Equal(0, dpl(4999, 4999), "Source is larger than dest, but not by a factor of 2, so level has to be 0", t) 19 | assert.Equal(0, dpl(2501, 2501), "Source is just under 2x dest, so level still has to be 0", t) 20 | assert.Equal(1, dpl(2500, 2500), "Source is exactly 2x dest, so level has to be 1", t) 21 | assert.Equal(1, dpl(2500, 250), "We have to pick the largest dimension, so level for 2500x250 should be 1", t) 22 | assert.Equal(2, dpl(1250, 0), "Use the non-zero dimension for resize package compatibility", t) 23 | } 24 | -------------------------------------------------------------------------------- /src/openjpeg/stream.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include "stream.h" 4 | #include "_cgo_export.h" 5 | 6 | OPJ_SIZE_T stream_read(void * p_buffer, OPJ_SIZE_T p_nb_bytes, void *stream_id) { 7 | return opjStreamRead(p_buffer, p_nb_bytes, (OPJ_UINT64)stream_id); 8 | } 9 | 10 | OPJ_OFF_T stream_skip(OPJ_OFF_T p_nb_bytes, void *stream_id) { 11 | return opjStreamSkip(p_nb_bytes, (OPJ_UINT64)stream_id); 12 | } 13 | 14 | OPJ_BOOL stream_seek(OPJ_OFF_T p_nb_bytes, void *stream_id) { 15 | return opjStreamSeek(p_nb_bytes, (OPJ_UINT64)stream_id); 16 | } 17 | 18 | void free_stream(void *stream_id) { 19 | freeStream((OPJ_UINT64)stream_id); 20 | } 21 | 22 | opj_stream_t* new_stream(OPJ_UINT64 buffer_size, OPJ_UINT64 stream_id, OPJ_UINT64 data_size) { 23 | opj_stream_t* l_stream = 00; 24 | 25 | l_stream = opj_stream_create(buffer_size, 1); 26 | if (! l_stream) { 27 | return NULL; 28 | } 29 | 30 | opj_stream_set_user_data(l_stream, (void*)stream_id, free_stream); 31 | opj_stream_set_user_data_length(l_stream, data_size); 32 | opj_stream_set_read_function(l_stream, (opj_stream_read_fn) stream_read); 33 | opj_stream_set_skip_function(l_stream, (opj_stream_skip_fn) stream_skip); 34 | opj_stream_set_seek_function(l_stream, (opj_stream_seek_fn) stream_seek); 35 | 36 | return l_stream; 37 | } 38 | -------------------------------------------------------------------------------- /src/openjpeg/stream.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | extern opj_stream_t* new_stream(OPJ_UINT64 buffer_size, OPJ_UINT64 stream_id, OPJ_UINT64 data_size); 4 | extern void GoLog(int level, char *message); 5 | -------------------------------------------------------------------------------- /src/plugins/datadog/main.go: -------------------------------------------------------------------------------- 1 | // This file is an example of integrating an external APM system (DataDog) 2 | // which needs to be able to do some setup, wrap all handlers, and do teardown 3 | // when RAIS is shutting down. Use of this plugin should be fairly 4 | // straightforward, but you will have to add some configuration for DataDog. 5 | // 6 | // First, you must set up a DataDog agent. If you do this with compose, 7 | // DD_API_KEY should be added to your .env, but you should be able to use our 8 | // demo compose.yml file otherwise. 9 | // 10 | // Then, "DatadogAddress" must be added to your rais.toml or else 11 | // RAIS_DATADOGADDRESS must be in your RAIS environment. This is shown in our 12 | // demo compose.yml. 13 | // 14 | // If you want to set a custom service name, set "DatadogServiceName" or else 15 | // expose RAIS_DatadogServiceName in your environment. The default service 16 | // name is "RAIS/datadog". 17 | // 18 | // If you want instrumentation that goes deeper than request round-tripping, 19 | // please be aware that RAIS does not currently support this. 20 | 21 | package main 22 | 23 | import ( 24 | "net/http" 25 | 26 | "github.com/spf13/viper" 27 | "github.com/uoregon-libraries/gopkg/logger" 28 | httptrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/net/http" 29 | "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" 30 | ) 31 | 32 | var l *logger.Logger 33 | var serviceName string 34 | 35 | // Disabled lets the plugin manager know not to add this plugin's functions to 36 | // the global list unless sanity checks in Initialize() pass 37 | var Disabled = true 38 | 39 | // Initialize reads configuration and sets up the datadog agent 40 | func Initialize() { 41 | var ddaddr = viper.GetString("DatadogAddress") 42 | viper.SetDefault("DatadogServiceName", "RAIS/datadog") 43 | serviceName = viper.GetString("DatadogServiceName") 44 | 45 | if ddaddr == "" { 46 | l.Warnf("DatadogAddress must be configured, or RAIS_DATADOGADDRESS must be set in the environment **DataDog plugin is disabled**") 47 | return 48 | } 49 | 50 | Disabled = false 51 | l.Debugf("Connecting to datadog agent at %q", ddaddr) 52 | tracer.Start(tracer.WithAgentAddr(ddaddr)) 53 | } 54 | 55 | // WrapHandler takes all RAIS routes' handlers and puts the datadog 56 | // instrumentation into them 57 | func WrapHandler(pattern string, handler http.Handler) (http.Handler, error) { 58 | return httptrace.WrapHandler(handler, serviceName, pattern), nil 59 | } 60 | 61 | // Teardown tells datadog to shut down the tracer gracefully 62 | func Teardown() { 63 | tracer.Stop() 64 | } 65 | 66 | // SetLogger is called by the RAIS server's plugin manager to let plugins use 67 | // the central logger 68 | func SetLogger(raisLogger *logger.Logger) { 69 | l = raisLogger 70 | } 71 | -------------------------------------------------------------------------------- /src/plugins/imagick-decoder/convert.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | /* 4 | #cgo pkg-config: MagickCore 5 | #include 6 | #include "magick.h" 7 | */ 8 | import "C" 9 | import ( 10 | "fmt" 11 | "image" 12 | "reflect" 13 | "unsafe" 14 | ) 15 | 16 | // image returns a native Go image interface. For now, this is always RGBA for 17 | // simplicity, but it would be a good idea to use a gray image when it makes 18 | // sense to improve performance and RAM usage. 19 | func (i *Image) image(cimg *C.Image) (image.Image, error) { 20 | // Create and prep-for-freeing the exception 21 | exception := C.AcquireExceptionInfo() 22 | defer C.DestroyExceptionInfo(exception) 23 | 24 | img := image.NewRGBA(image.Rect(0, 0, i.decodeWidth, i.decodeHeight)) 25 | 26 | area := i.decodeWidth * i.decodeHeight 27 | pixLen := area << 2 28 | pixels := make([]byte, pixLen) 29 | pi := reflect.ValueOf(pixels).Interface() 30 | ptr := unsafe.Pointer(&pixels[0]) 31 | 32 | // Dimensions as C types 33 | w := C.size_t(i.decodeWidth) 34 | h := C.size_t(i.decodeHeight) 35 | 36 | var err = i.attemptExportRGBA(cimg, w, h, ptr, exception, 0) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | var ok bool 42 | img.Pix, ok = pi.([]uint8) 43 | if !ok { 44 | return nil, fmt.Errorf("unable to cast img.Pix to []uint8") 45 | } 46 | 47 | return img, nil 48 | } 49 | 50 | func (i *Image) attemptExportRGBA(cimg *C.Image, w, h C.size_t, ptr unsafe.Pointer, ex *C.ExceptionInfo, tries int) (err error) { 51 | defer func() { 52 | if x := recover(); x != nil { 53 | if tries < 3 { 54 | l.Warnf("Error trying to decode from ImageMagick (trying again): %s", x) 55 | _ = i.attemptExportRGBA(cimg, w, h, ptr, ex, tries+1) 56 | } else { 57 | l.Errorf("Error trying to decode from ImageMagick: %s", x) 58 | err = fmt.Errorf("imagemagick failure: %s", x) 59 | } 60 | } 61 | }() 62 | 63 | C.ExportRGBA(cimg, w, h, ptr, ex) 64 | return err 65 | } 66 | -------------------------------------------------------------------------------- /src/plugins/imagick-decoder/magick.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include "magick.h" 3 | 4 | void SetImageInfoFilename(ImageInfo *image_info, char *filename) { 5 | (void) CopyMagickString(image_info->filename,filename,MaxTextExtent); 6 | } 7 | 8 | int HasError(ExceptionInfo *exception) { 9 | register const ExceptionInfo *p; 10 | int result = 0; 11 | 12 | assert(exception != (ExceptionInfo *) NULL); 13 | assert(exception->signature == MagickSignature); 14 | if (exception->exceptions == (void *) NULL) 15 | return 0; 16 | if (exception->semaphore == (void *) NULL) 17 | return 0; 18 | 19 | LockSemaphoreInfo(exception->semaphore); 20 | ResetLinkedListIterator((LinkedListInfo *) exception->exceptions); 21 | p=(const ExceptionInfo *) GetNextValueInLinkedList((LinkedListInfo *) 22 | exception->exceptions); 23 | while (p != (const ExceptionInfo *) NULL) { 24 | if (p->severity >= ErrorException) 25 | result = 1; 26 | p=(const ExceptionInfo *) GetNextValueInLinkedList((LinkedListInfo *) 27 | exception->exceptions); 28 | } 29 | UnlockSemaphoreInfo(exception->semaphore); 30 | return result; 31 | } 32 | 33 | void ExportRGBA(Image *image, size_t w, size_t h, void *pixels, ExceptionInfo *e) { 34 | ExportImagePixels(image, 0, 0, w, h, "RGBA", CharPixel, pixels, e); 35 | } 36 | 37 | RectangleInfo MakeRectangle(int x, int y, int w, int h) { 38 | RectangleInfo ri; 39 | ri.x = x; 40 | ri.y = y; 41 | ri.width = w; 42 | ri.height = h; 43 | 44 | return ri; 45 | } 46 | 47 | Image *Resize(Image *image, size_t w, size_t h, ExceptionInfo *e) { 48 | return AdaptiveResizeImage(image, w, h, e); 49 | } 50 | -------------------------------------------------------------------------------- /src/plugins/imagick-decoder/magick.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | extern void SetImageInfoFilename(ImageInfo *image_info, char *filename); 5 | extern int HasError(ExceptionInfo *exception); 6 | extern void ExportRGBA(Image *image, size_t w, size_t h, void *pixels, ExceptionInfo *e); 7 | extern RectangleInfo MakeRectangle(int x, int y, int w, int h); 8 | extern Image *Resize(Image *image, size_t w, size_t h, ExceptionInfo *e); 9 | -------------------------------------------------------------------------------- /src/plugins/imagick-decoder/main.go: -------------------------------------------------------------------------------- 1 | // Package magick is a hacked up port of the minimal functionality we need 2 | // to satisfy the img.Decoder interface. Code is based in part on 3 | // github.com/quirkey/magick 4 | package main 5 | 6 | /* 7 | #cgo pkg-config: MagickCore 8 | #include 9 | */ 10 | import "C" 11 | import ( 12 | "fmt" 13 | "net/url" 14 | "os" 15 | "path/filepath" 16 | "rais/src/img" 17 | "rais/src/plugins" 18 | "strings" 19 | "sync" 20 | "unsafe" 21 | 22 | "github.com/uoregon-libraries/gopkg/logger" 23 | ) 24 | 25 | var l *logger.Logger 26 | var m sync.Mutex 27 | 28 | // SetLogger is called by the RAIS server's plugin manager to let plugins use 29 | // the central logger 30 | func SetLogger(raisLogger *logger.Logger) { 31 | l = raisLogger 32 | } 33 | 34 | // Initialize sets up the MagickCore stuff and registers the TIFF, PNG, JPG, 35 | // and GIF decoders 36 | func Initialize() { 37 | path, _ := os.Getwd() 38 | cPath := C.CString(path) 39 | defer C.free(unsafe.Pointer(cPath)) 40 | C.MagickCoreGenesis(cPath, C.MagickFalse) 41 | C.SetMagickResourceLimit(C.DiskResource, C.MagickResourceInfinity) 42 | img.RegisterDecodeHandler(decodeCommonFile) 43 | } 44 | 45 | func makeError(where string, exception *C.ExceptionInfo) error { 46 | var reason = C.GoString(exception.reason) 47 | var description = C.GoString(exception.description) 48 | return fmt.Errorf("ImageMagick/%s: API Error #%v: %q - %q", where, exception.severity, reason, description) 49 | } 50 | 51 | var validExtensions = []string{".tif", ".tiff", ".png", ".jpg", ".jpeg", ".gif"} 52 | 53 | func validExt(u *url.URL) bool { 54 | var ext = strings.ToLower(filepath.Ext(u.Path)) 55 | for _, validExt := range validExtensions { 56 | if ext == validExt { 57 | return true 58 | } 59 | } 60 | 61 | return false 62 | } 63 | 64 | func validScheme(u *url.URL) bool { 65 | return u.Scheme == "file" 66 | } 67 | 68 | func decodeCommonFile(s img.Streamer) (img.DecodeFunc, error) { 69 | var u = s.Location() 70 | if !validExt(u) { 71 | l.Debugf("plugins/imagick-decoder: skipping unsupported image extension %q (must be one of %s)", 72 | s.Location(), strings.Join(validExtensions, ", ")) 73 | return nil, plugins.ErrSkipped 74 | } 75 | 76 | if !validScheme(u) { 77 | l.Debugf("plugins/imagick-decoder: skipping unsupported URL scheme %q (must be file)", u.Scheme) 78 | return nil, plugins.ErrSkipped 79 | } 80 | 81 | return func() (img.Decoder, error) { return NewImage(u.Path) }, nil 82 | } 83 | -------------------------------------------------------------------------------- /src/plugins/imagick-decoder/resources.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | /* 4 | #cgo pkg-config: MagickCore 5 | #include 6 | */ 7 | import "C" 8 | 9 | func cleanupImage(i *C.Image) { 10 | if i != nil { 11 | if i.next != nil { 12 | C.DestroyImageList(i) 13 | } else { 14 | C.DestroyImage(i) 15 | } 16 | } 17 | } 18 | 19 | func cleanupImageInfo(i *C.ImageInfo) { 20 | if i != nil { 21 | C.DestroyImageInfo(i) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/plugins/json-tracer/main.go: -------------------------------------------------------------------------------- 1 | // This file creates a plugin for instrumenting RAIS for internal use. Usage 2 | // will require a new configuration value, "TracerOut", or an environment value 3 | // in RAIS_TRACEROUT. 4 | // 5 | // To use this with compose, do something like this in compose.override.yml: 6 | // 7 | // environment: 8 | // - RAIS_TRACEROUT=/tmp/rais-traces.json 9 | // - RAIS_TRACERFLUSHSECONDS=10 10 | 11 | package main 12 | 13 | import ( 14 | "net/http" 15 | "time" 16 | 17 | "github.com/spf13/viper" 18 | "github.com/uoregon-libraries/gopkg/logger" 19 | ) 20 | 21 | var l *logger.Logger 22 | var jsonOut string 23 | var reg *registry 24 | 25 | // Disabled lets the plugin manager know not to add this plugin's functions to 26 | // the global list unless sanity checks in Initialize() pass 27 | var Disabled = true 28 | 29 | // flushTime is the duration after which events are flushed to disk 30 | var flushTime time.Duration 31 | 32 | // Initialize reads configuration and sets up the JSON output directory 33 | func Initialize() { 34 | viper.SetDefault("TracerFlushSeconds", 10) 35 | flushTime = time.Second * time.Duration(viper.GetInt("TracerFlushSeconds")) 36 | jsonOut = viper.GetString("TracerOut") 37 | 38 | if jsonOut == "" { 39 | l.Warnf("TracerOut must be configured, or RAIS_TRACEROUT must be set in the environment **JSON Tracer plugin is disabled**") 40 | return 41 | } 42 | 43 | reg = new(registry) 44 | 45 | Disabled = false 46 | } 47 | 48 | // WrapHandler takes all RAIS routes' handlers and wraps them with the JSON 49 | // tracer middleware 50 | func WrapHandler(_ string, handler http.Handler) (http.Handler, error) { 51 | return reg.new(handler), nil 52 | } 53 | 54 | // SetLogger is called by the RAIS server's plugin manager to let plugins use 55 | // the central logger 56 | func SetLogger(raisLogger *logger.Logger) { 57 | l = raisLogger 58 | } 59 | 60 | // Teardown writes all pending information to the JSON directory 61 | func Teardown() { 62 | reg.shutdown() 63 | } 64 | -------------------------------------------------------------------------------- /src/plugins/json-tracer/sr.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "net/http" 4 | 5 | type statusRecorder struct { 6 | http.ResponseWriter 7 | status int 8 | } 9 | 10 | func (sr *statusRecorder) WriteHeader(code int) { 11 | sr.status = code 12 | sr.ResponseWriter.WriteHeader(code) 13 | } 14 | -------------------------------------------------------------------------------- /src/plugins/json-tracer/tracer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "rais/src/iiif" 6 | "strings" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | type event struct { 12 | Path string 13 | Type string 14 | Start time.Time 15 | Duration float64 16 | Status int 17 | } 18 | 19 | type tracer struct { 20 | sync.Mutex 21 | done chan bool 22 | nextFlushTime time.Time 23 | handler http.Handler 24 | events []event 25 | writeFailures int 26 | } 27 | 28 | // ServeHTTP implements http.Handler. We call the underlying handler and store 29 | // timing data locally. 30 | func (t *tracer) ServeHTTP(w http.ResponseWriter, req *http.Request) { 31 | var sr = statusRecorder{w, 200} 32 | var path = req.URL.RawPath 33 | if path == "" { 34 | path = req.URL.Path 35 | } 36 | 37 | var start = time.Now() 38 | t.handler.ServeHTTP(&sr, req) 39 | var finish = time.Now() 40 | 41 | // To avoid blocking when the events are being processed, we send the event 42 | // to the tracer's list asynchronously 43 | go t.appendEvent(path, start, finish, sr.status) 44 | } 45 | 46 | // getReqType is a bit ugly and hacky, but attempts to determine what kind of 47 | // IIIF request we have, if any. Determining type isn't as easy without 48 | // peeking at RAIS's config, which seems like it could break one day (say the 49 | // IIIF URL config changes but we forget to update this plugin). Instead, we 50 | // just do what we can by pulling the URL apart as necessary to make pretty 51 | // good guesses. 52 | func getReqType(path string) string { 53 | if len(path) < 9 { 54 | return "None" 55 | } 56 | 57 | if path[len(path)-9:] == "info.json" { 58 | return "Info" 59 | } 60 | 61 | var parts = strings.Split(path, "/") 62 | if len(parts) < 5 { 63 | return "None" 64 | } 65 | 66 | var iiifPath = strings.Join(parts[len(parts)-5:], "/") 67 | var u, err = iiif.NewURL(iiifPath) 68 | if err != nil || !u.Valid() { 69 | return "None" 70 | } 71 | 72 | if err == nil { 73 | if u.Region.Type == iiif.RTFull || u.Region.Type == iiif.RTSquare { 74 | return "Resize" 75 | } else if u.Size.W <= 1024 && u.Size.H <= 1024 { 76 | return "Tile" 77 | } 78 | } 79 | 80 | return "Unknown" 81 | } 82 | 83 | func (t *tracer) appendEvent(path string, start, finish time.Time, status int) { 84 | t.Lock() 85 | defer t.Unlock() 86 | 87 | t.events = append(t.events, event{ 88 | Path: path, 89 | Type: getReqType(path), 90 | Start: start, 91 | Duration: finish.Sub(start).Seconds(), 92 | Status: status, 93 | }) 94 | } 95 | 96 | // loop checks regularly for the last flush having been long enough ago to 97 | // flush to disk again. This must run in a background goroutine. 98 | func (t *tracer) loop() { 99 | for { 100 | select { 101 | case <-t.done: 102 | return 103 | default: 104 | if t.ready() { 105 | t.flush() 106 | } 107 | time.Sleep(time.Second) 108 | } 109 | } 110 | } 111 | 112 | func (t *tracer) shutdown(wg *sync.WaitGroup) { 113 | t.flush() 114 | t.done <- true 115 | wg.Done() 116 | } 117 | 118 | type registry struct { 119 | list []*tracer 120 | } 121 | 122 | func makeEvents() []event { 123 | return make([]event, 0, 256) 124 | } 125 | 126 | func (r *registry) new(h http.Handler) *tracer { 127 | var t = &tracer{ 128 | handler: h, 129 | events: makeEvents(), 130 | nextFlushTime: time.Now().Add(flushTime), 131 | done: make(chan bool, 1), 132 | } 133 | go t.loop() 134 | r.list = append(r.list, t) 135 | return t 136 | } 137 | 138 | func (r *registry) shutdown() { 139 | var wg sync.WaitGroup 140 | for _, t := range r.list { 141 | wg.Add(1) 142 | go t.shutdown(&wg) 143 | } 144 | 145 | wg.Wait() 146 | } 147 | -------------------------------------------------------------------------------- /src/plugins/json-tracer/write.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "time" 7 | ) 8 | 9 | // ready returns true if the tracer is ready to flush events to disk 10 | func (t *tracer) ready() bool { 11 | t.Lock() 12 | defer t.Unlock() 13 | return time.Now().After(t.nextFlushTime) && len(t.events) > 0 14 | } 15 | 16 | func (t *tracer) flush() { 17 | t.Lock() 18 | defer t.Unlock() 19 | 20 | t.nextFlushTime = time.Now().Add(flushTime) 21 | 22 | // This is only necessary for forced flushing, such as on shutdown 23 | if len(t.events) == 0 { 24 | return 25 | } 26 | 27 | // Generate the JSON output first so we can report truly fatal errors before bothering with file IO 28 | var towrite []byte 29 | for _, ev := range t.events { 30 | var bytes, err = json.Marshal(ev) 31 | if err != nil { 32 | l.Errorf("json-tracer plugin: skipping 1 event: unable to marshal event (%#v) data: %s", ev, err) 33 | continue 34 | } 35 | towrite = append(towrite, bytes...) 36 | towrite = append(towrite, '\n') 37 | } 38 | 39 | var f, err = os.OpenFile(jsonOut, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666) 40 | if err != nil { 41 | l.Errorf("json-tracer plugin: unable to open %q for appending: %s", jsonOut, err) 42 | t.addWriteFailure() 43 | return 44 | } 45 | defer f.Close() 46 | 47 | _, err = f.Write(towrite) 48 | if err != nil { 49 | l.Errorf("json-tracer plugin: unable to write events: %s", err) 50 | t.addWriteFailure() 51 | return 52 | } 53 | 54 | err = f.Close() 55 | if err != nil { 56 | l.Errorf("json-tracer plugin: unable to close %q: %s", jsonOut, err) 57 | t.addWriteFailure() 58 | return 59 | } 60 | 61 | if t.writeFailures > 0 { 62 | l.Infof("json-tracer plugin: successfully wrote held events") 63 | } 64 | t.events = makeEvents() 65 | t.writeFailures = 0 66 | } 67 | 68 | func (t *tracer) addWriteFailure() { 69 | // Let's max out at 8 failures to avoid waiting so long that the next flush never happens 70 | if t.writeFailures < 8 { 71 | t.writeFailures++ 72 | } 73 | 74 | // We'll forcibly change the next flush attempt so we can recover quickly if 75 | // the problem is short-lived, but try less and less often the more failures 76 | // we've logged 77 | var sec = 1 << uint(t.writeFailures) 78 | t.nextFlushTime = time.Now().Add(time.Second * time.Duration(sec)) 79 | l.Warnf("json-tracer plugin: next flush() in %d second(s)", sec) 80 | 81 | // If we've been failing for a while and we have over 10k stored events, we start dropping some.... 82 | var elen = len(t.events) 83 | if t.writeFailures > 5 && elen > 10000 { 84 | t.events = t.events[elen-10000:] 85 | l.Criticalf("json-tracer plugin: continued write failures has resulted in dropping %d events", elen-len(t.events)) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/plugins/plugins.go: -------------------------------------------------------------------------------- 1 | // Package plugins holds some example (optional) plugins to demonstrate how 2 | // RAIS could be extended. These plugins are not necessarily fleshed out 3 | // fully, and are intended more to show what's possible than what may be 4 | // useful. 5 | package plugins 6 | 7 | import "errors" 8 | 9 | // ErrSkipped is an error plugins can return to state that they didn't actually 10 | // handle a given task, and other plugins should be used instead. It shouldn't 11 | // generally be reported, as it's not a situation that's concerning (much like 12 | // io.EOF when reading a file). 13 | var ErrSkipped = errors.New("plugin doesn't handle this feature") 14 | -------------------------------------------------------------------------------- /src/transform/generator.go: -------------------------------------------------------------------------------- 1 | // +build ignore 2 | // 3 | // This file builds the rotation code: `go run transform/generator.go` 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "text/template" 11 | ) 12 | 13 | type rotation struct { 14 | Method string 15 | Comment string 16 | getDstXBase string 17 | GetDstY string 18 | DimensionOrder string 19 | } 20 | 21 | func valTimesInt(s string, i int) string { 22 | switch i { 23 | case 1: 24 | return s 25 | case 2: 26 | return fmt.Sprintf("(%s << 1)", s) 27 | case 4: 28 | return fmt.Sprintf("(%s << 2)", s) 29 | case 8: 30 | return fmt.Sprintf("(%s << 3)", s) 31 | default: 32 | return fmt.Sprintf("(%s * %d)", s, i) 33 | } 34 | } 35 | 36 | func (r rotation) GetSrcX(i int) string { 37 | return valTimesInt("x", i) 38 | } 39 | 40 | func (r rotation) GetDstX(i int) string { 41 | return valTimesInt(r.getDstXBase, i) 42 | } 43 | 44 | var rotate90 = rotation{ 45 | Method: "Rotate90", 46 | Comment: "does a simple 90-degree clockwise rotation", 47 | getDstXBase: "(maxY - 1 - y)", 48 | GetDstY: "x", 49 | DimensionOrder: "srcHeight, srcWidth", 50 | } 51 | 52 | var rotate180 = rotation{ 53 | Method: "Rotate180", 54 | Comment: "does a simple 180-degree clockwise rotation", 55 | getDstXBase: "(maxX - 1 - x)", 56 | GetDstY: "(maxY - 1 - y)", 57 | DimensionOrder: "srcWidth, srcHeight", 58 | } 59 | 60 | var rotate270 = rotation{ 61 | Method: "Rotate270", 62 | Comment: "does a simple 270-degree clockwise rotation", 63 | getDstXBase: "y", 64 | GetDstY: "(maxX - 1 - x)", 65 | DimensionOrder: "srcHeight, srcWidth", 66 | } 67 | 68 | var rotateMirror = rotation{ 69 | Method: "Mirror", 70 | Comment: "flips the image around its vertical axis", 71 | getDstXBase: "(maxX - 1 - x)", 72 | GetDstY: "y", 73 | DimensionOrder: "srcWidth, srcHeight", 74 | } 75 | 76 | type imageType struct { 77 | String string 78 | Shortstring string 79 | ConstructorMethod string 80 | CopyStatement string 81 | ByteSize int 82 | } 83 | 84 | var typeGray = imageType{ 85 | String: "*image.Gray", 86 | Shortstring: "Gray", 87 | ConstructorMethod: "image.NewGray", 88 | CopyStatement: "dstPix[dstIdx] = srcPix[srcIdx]", 89 | ByteSize: 1, 90 | } 91 | 92 | var typeRGBA = imageType{ 93 | String: "*image.RGBA", 94 | Shortstring: "RGBA", 95 | ConstructorMethod: "image.NewRGBA", 96 | CopyStatement: "copy(dstPix[dstIdx:dstIdx+4], srcPix[srcIdx:srcIdx+4])", 97 | ByteSize: 4, 98 | } 99 | 100 | type page struct { 101 | Rotations []rotation 102 | Types []imageType 103 | } 104 | 105 | func main() { 106 | t := template.Must(template.ParseFiles("src/transform/template.txt")) 107 | f, err := os.Create("src/transform/rotation.go") 108 | if err != nil { 109 | fmt.Println("ERROR creating file:", err) 110 | } 111 | 112 | p := page{ 113 | Rotations: []rotation{rotate90, rotate180, rotate270, rotateMirror}, 114 | Types: []imageType{typeGray, typeRGBA}, 115 | } 116 | 117 | err = t.Execute(f, p) 118 | if err != nil { 119 | fmt.Println("ERROR:", err) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/transform/template.txt: -------------------------------------------------------------------------------- 1 | // GENERATED CODE; DO NOT EDIT! 2 | 3 | package transform{{$Rotations := .Rotations}} 4 | 5 | import ( 6 | "image" 7 | ) 8 | 9 | // Rotator implements simple 90-degree rotations in addition to mirroring for 10 | // IIIF compliance. After each operation, the underlying image is replaced 11 | // with the new image. It's important to note, however, that the source image 12 | // is never directly changed. A new image is drawn, and the old is simply 13 | // forgotten by the Rotator. 14 | type Rotator interface { 15 | Image() image.Image 16 | {{range $Rotations}} {{.Method}}() 17 | {{end}}} 18 | 19 | {{range .Types}} 20 | {{$Type := .}} 21 | 22 | // {{.Shortstring}}Rotator decorates {{.String}} with rotation functions 23 | type {{.Shortstring}}Rotator struct { 24 | Img {{.String}} 25 | } 26 | 27 | // Image returns the underlying image as an image.Image value 28 | func (r *{{$Type.Shortstring}}Rotator) Image() image.Image { 29 | return r.Img 30 | } 31 | 32 | {{range $Rotations}} 33 | // {{.Method}} {{.Comment}} 34 | func (r *{{$Type.Shortstring}}Rotator) {{.Method}}() { 35 | src := r.Img 36 | srcB := src.Bounds() 37 | srcWidth := srcB.Dx() 38 | srcHeight := srcB.Dy() 39 | 40 | dst := {{$Type.ConstructorMethod}}(image.Rect(0, 0, {{.DimensionOrder}})) 41 | 42 | var x, y, srcIdx, dstIdx int64 43 | maxX, maxY := int64(srcWidth), int64(srcHeight) 44 | srcStride, dstStride := int64(src.Stride), int64(dst.Stride) 45 | srcPix := src.Pix 46 | dstPix := dst.Pix 47 | for y = 0; y < maxY; y++ { 48 | for x = 0; x < maxX; x++ { 49 | srcIdx = y*srcStride + {{.GetSrcX $Type.ByteSize}} 50 | dstIdx = {{.GetDstY}}*dstStride + {{.GetDstX $Type.ByteSize}} 51 | {{$Type.CopyStatement}} 52 | } 53 | } 54 | 55 | r.Img = dst 56 | } 57 | {{end}} 58 | {{end}} 59 | // GENERATED CODE; DO NOT EDIT! 60 | -------------------------------------------------------------------------------- /src/version/version.go: -------------------------------------------------------------------------------- 1 | // Package version is just for holding high-level versioning information for 2 | // the project as a whole 3 | package version 4 | 5 | // Version is the raw version string. This is set at compile time via a "make" 6 | // invocation. If you don't use make, you better read it to build the correct 7 | // version data.... 8 | var Version = "" 9 | --------------------------------------------------------------------------------