Christopher Vistal, home
GitHub LinkedIn
← Back to blog
Blog  ·  university platform

How to Build a Secure Multi-Campus File Platform with Laravel and Go

A Google Drive-style platform for a multi-campus state university. The brief in one sentence: stop scattering files across campuses, inboxes, and USB sticks, and give every department and student team a single place to work together.

Role
Engineer, build + ongoing maintenance
Stack
Laravel, Vue.js (Inertia.js), MySQL, Go (tus upload service + PDF signer), malware scanning, S3 + local FS, ONLYOFFICE, OAuth single sign-on, Tailwind CSS
Status
In production across the university's campuses

Published · Updated

Illustration of a cloud, a shield with a check mark, a stack of signed documents, a row of folders and an upload button on a stone base.
Illustration of the platform's jobs: shared storage, scanned uploads, signed documents and folders.

A multi-campus state university moved its files around by email attachment and USB stick. It wanted one place where every file lives, with the right people able to see it, and student submissions that stop bouncing through inboxes.

A shared file platform for a whole institution has to get four things right: who can see what, uploads that survive bad networks and exam-week traffic, files from untrusted accounts, and documents that must be provably authentic. This post shows how I modeled ownership and visibility in Laravel, and why resumable uploads and PDF signing went to small Go services, with generic examples you can adapt.

Background

Where the university started

The university runs across multiple campuses. Each one has its own admin office, its own faculty, and its own way of moving paperwork around. The shared layer between them was, in practice, email attachments and the occasional USB stick brought to a meeting.

Submissions from students to instructors lived in inboxes. Department reports for the central office lived in inboxes. Memos from administration to faculty lived in inboxes. Every time someone needed last quarter's file, they searched for it in their own inbox, and if they couldn't find it, asked the author to resend.

The university wanted what every organization eventually wants: one place where the file lives, with the right people able to see it. A Google Drive that knew about campuses, departments, faculty, and student teams.

Constraints

Two terms used below

The shape of the system

The headline picture is a Laravel + Inertia monolith that owns authentication, authorization, file metadata, submissions, and the audit log. The honest picture has three sidecars: a Go upload service, a malware scanner, and a Go PDF signer. Each one exists because PHP was the wrong tool for a specific job and pretending otherwise would have hurt the platform.

Records live in MySQL. File contents live in two tiers, local disk for hot data and object storage for cold and shared data, covered further down.

Modelling a university as a graph

Every file in the system belongs to one of four owners: a campus, a department, a team, or a person. Everything else, visibility, sharing, inheritance, falls out of getting those four right.

A user belongs to a campus. They may belong to one or more departments inside that campus. They may belong to one or more teams (a research group, a class section, an admin committee). When a file is uploaded, the upload chooses an owner. The owner decides the file's default visibility, and the visibility decides who can find it in search. Checking access becomes one question per owner type: is this user part of the campus, the department, the team, or are they the person (or someone the file was shared with)?

Sharing layers on top. A file with a personal owner can be explicitly shared with a team. A team file can be promoted to the department. A campus-owned policy file can be readable by every user in the system, but only editable by campus admins. The graph fans out from the four owner types and stays comprehensible.

Submissions as first-class objects

The hard part wasn't the file storage. The hard part was submissions, the recurring student-to-instructor workflow that had been bouncing through email forever.

A submission is not just a file. It's a request, a deadline, a list of accepted file types, a list of expected submitters, and a feedback loop. The instructor creates a submission slot for a team or a class section. Students see it in their dashboard with a countdown. They upload. The instructor reviews, leaves a comment, and either accepts or returns it for revision. The student sees the result on the same screen.

Modeling it explicitly meant we could do things email could never do cleanly: show every student whether their classmates have submitted (without revealing the submission contents), auto-close at the deadline, and regenerate a clean folder of all submissions for the instructor with one click at the end.

Resumable uploads: Go + tus, not PHP

The first upload pipeline ran through PHP. It worked, until exam week. Hundreds of students submitting on the same afternoon, dozens of faculty syncing folders in the background, and PHP-FPM spent more time juggling long-lived multipart requests than serving the actual app. PHP is great at short request/response cycles. It is not great at hundreds of concurrent streaming uploads.

So I broke uploads off into a Go service speaking the tus protocol, the open standard for resumable file uploads. The Laravel app still owns authorization and metadata. When a user wants to upload, Laravel checks that they may write to the target folder, then hands the client a short-lived signed upload token that carries the destination and the user's remaining quota. The client uploads to the Go service in chunks. If the connection drops at chunk 7 of 12, the next request resumes at chunk 8 instead of starting over.

The Go service runs one goroutine per active upload, eats hundreds of concurrent streams comfortably on the same server, and never blocks the PHP workers. After exam week stopped feeling like exam week, the architecture earned its place.

Malware scanning on every upload

The platform accepts files from a large population of student accounts. Treating them as trusted by default is a bad plan. I wired malware scanning into the post-upload pipeline so every file gets scanned before it becomes visible in the system.

When the Go upload service finishes assembling a file, it notifies Laravel, which queues a scan job. A file stays hidden until the scan comes back. Clean files become visible. Infected files are quarantined, moved out of the user-visible path, and administrators are alerted.

One scan per upload, no exceptions. False positives are rare and easy for an administrator to release. The cost of running a scanner is small compared to the cost of an institution-wide platform serving its first piece of malware.

PDF signing in Go: digital signatures on official documents

Universities generate official documents constantly: certificates, transcripts, memos, signed approvals. A PDF downloaded from the platform needs to be verifiable as authentic, not just visually convincing. That means real cryptographic signatures embedded into the PDF itself, not a watermark.

PHP's PDF signing story is workable but slow and fiddly. Go's ecosystem here is cleaner. I wrote a small Go signer service that accepts a PDF and a context payload, embeds an X.509 signature using the PKCS#7 detached profile, and returns the signed bytes. When a document is finalized, Laravel sends it to the signer and stores what comes back. The Go service is the only process that touches the private key, Laravel never sees it.

Validators in Adobe Reader and the OS-level PDF viewers confirm the signature against the institution's certificate chain. The signed PDF carries its own provenance even after it leaves the platform.

Hot vs cold: S3 and local FS, both

Storage isn't one thing. I run a two-tier blob layer: local file system for hot data and shared Amazon S3 for cold + cross-campus access.

When a file is requested, Laravel checks the local cache first and falls back to S3. Reads from S3 warm the local cache so the next read is fast. The user never has to know which tier the bytes came from.

Lesson Authorization rules, upload reliability, virus scanning, signed PDFs, and tiered storage are where a university platform earns its keep. Picking the right language for each one of those jobs, PHP for the app, Go for the long-lived and security-sensitive bits, was the difference between a portal that survived exam week and one that didn't.

Editing documents in the browser with ONLYOFFICE

A lot of the university's living documents, class lists, grading rubrics, departmental rosters, committee minutes, are office files that change every week. The first version of the platform only let users upload and download them, which everyone hated. Faculty wanted to keep editing the real file, not download it, edit locally, and re-upload after every change.

So the platform embeds ONLYOFFICE. Opening a document or spreadsheet in the platform launches the ONLYOFFICE editor in the browser, against the file the platform already stores. When an editing session ends, ONLYOFFICE hands the saved file back to the platform, which stores it as the file's new content. The file in the platform stays the single source of truth, and the platform's access rules decide who can open the editor at all.

Single sign-on doubles as the platform's primary auth path. Most users already had an institutional account, making them create a second password was friction nobody asked for. The platform accepts those identities over OAuth and maps them to local user records.

What broke, or surprised me

Departments don't map cleanly to org charts

The first version assumed each department had one clear parent. Reality: cross-listed departments, joint programs, and instructors attached to several departments at once. I rewrote the membership model from "user has one department" to "user has many department memberships, each with a role." Slight schema change, much less hand-editing.

Search was the second-most-used feature

The folder tree did most of the work but search was a close second. Faculty using the system long enough stopped navigating to files and just typed names. I tuned the search path to do filename, owner, and content-aware indexing on common document types so the second-place feature wouldn't feel second-class.

Design decisions

The interface follows the model: who you are decides which surface you get and what you can find.

Practical examples

Simplified example. The snippets below are generic Laravel and Go code showing the techniques from this post, with neutral names. They are not the production code or schema.

1. Decide access from the owner type

class DocumentPolicy
{
    public function view(User $user, Document $document): bool
    {
        // Explicit shares always grant access.
        if ($document->shares()->where('user_id', $user->getKey())->exists()) {
            return true;
        }

        // Otherwise: one question per owner type.
        return match ($document->owner_type) {
            'campus'     => $user->campus_id === $document->owner_id,
            'department' => $user->departments()->whereKey($document->owner_id)->exists(),
            'team'       => $user->teams()->whereKey($document->owner_id)->exists(),
            'person'     => $user->getKey() === $document->owner_id,
            default      => false,
        };
    }
}

2. Accept resumable uploads in Go with tusd

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"log"
	"net/http"
	"os"

	"github.com/tus/tusd/v2/pkg/filestore"
	tusd "github.com/tus/tusd/v2/pkg/handler"
)

var secret = []byte(os.Getenv("UPLOAD_TOKEN_SECRET"))

// validToken checks an HMAC-SHA256 signature issued by the main app.
func validToken(payload, signature string) bool {
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(payload))
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signature))
}

func main() {
	store := filestore.New("./uploads")
	composer := tusd.NewStoreComposer()
	store.UseIn(composer)

	handler, err := tusd.NewHandler(tusd.Config{
		BasePath:              "/files/",
		StoreComposer:         composer,
		NotifyCompleteUploads: true,
		// Reject the upload unless the main app signed the token.
		PreUploadCreateCallback: func(hook tusd.HookEvent) (tusd.HTTPResponse, tusd.FileInfoChanges, error) {
			payload := hook.HTTPRequest.Header.Get("Upload-Token")
			signature := hook.HTTPRequest.Header.Get("Upload-Signature")
			if !validToken(payload, signature) {
				return tusd.HTTPResponse{}, tusd.FileInfoChanges{},
					tusd.NewError("ERR_INVALID_TOKEN", "invalid upload token", http.StatusUnauthorized)
			}
			return tusd.HTTPResponse{}, tusd.FileInfoChanges{}, nil
		},
	})
	if err != nil {
		log.Fatalf("unable to create handler: %s", err)
	}

	// Each finished upload is reported here; tell the main app to queue a scan.
	go func() {
		for event := range handler.CompleteUploads {
			log.Printf("upload %s finished (%d bytes)", event.Upload.ID, event.Upload.Size)
		}
	}()

	http.Handle("/files/", http.StripPrefix("/files/", handler))
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Expected behaviour: a client whose connection drops sends a HEAD request, reads the Upload-Offset header, and continues from that byte instead of starting over. The Go HTTP server handles each request on its own goroutine, so hundreds of slow uploads do not block each other.

3. Scan every upload before it becomes visible

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;

class ScanUpload implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public Document $document) {}

    public function handle(): void
    {
        $path = Storage::disk('local')->path($this->document->path);

        // clamdscan exit codes: 0 = clean, 1 = virus found, 2 = error.
        $result = Process::run(['clamdscan', '--no-summary', '--fdpass', $path]);

        match ($result->exitCode()) {
            0 => $this->document->update(['status' => 'clean']),
            1 => $this->quarantine(),
            default => throw new RuntimeException('Scan failed: '.$result->errorOutput()),
        };
    }

    private function quarantine(): void
    {
        $target = 'quarantine/'.basename($this->document->path);

        Storage::disk('local')->move($this->document->path, $target);
        $this->document->update(['status' => 'quarantined', 'path' => $target]);

        event(new UploadQuarantined($this->document));
    }
}

// Every listing only shows scanned, clean files.
Document::where('status', 'clean')->latest()->paginate(50);

Expected output when testing with the standard EICAR test file:

$ clamdscan --no-summary /tmp/eicar.com.txt
/tmp/eicar.com.txt: Win.Test.EICAR_HDB-1 FOUND
$ echo $?
1

4. Read from hot storage, fall back to S3

public function stream(Document $document)
{
    Gate::authorize('view', $document);

    $local = Storage::disk('local');

    // Cold file: copy it back from S3 so the next read is fast.
    if (! $local->exists($document->path)) {
        $local->writeStream($document->path, Storage::disk('s3')->readStream($document->path));
    }

    return $local->response($document->path);
}

Common mistakes

Best practices

Conclusion

Universities are sprawling. The temptation is always to rebuild Google Drive. The actual work is figuring out which of the university's own concepts, campus, department, team, submission, deserve to be first-class in the schema. The platform is only as useful as the model underneath it.

The takeaway: model your organization's real owners first, keep the main app in charge of authorization, and hand each long-lived or security-sensitive job to a tool that is good at it. A good next step is to list the owner types in your own domain and write down the single access question each one answers.

What it does today

What I'd do differently next time

References and further reading

Need a platform that fits how your organization actually works? I take on full-stack Laravel + Vue builds end-to-end.
Start a conversation